├── web ├── admin │ ├── public │ │ ├── favicon.ico │ │ ├── manifest.json │ │ └── index.html │ ├── src │ │ ├── components │ │ │ ├── markdown-editor │ │ │ │ ├── MarkdownEditor.css │ │ │ │ ├── MarkdownEditor.module.css │ │ │ │ └── MarkdownEditor.js │ │ │ ├── pagination │ │ │ │ ├── Pagination.module.css │ │ │ │ └── Pagination.js │ │ │ ├── filter-icon │ │ │ │ ├── FilterIcon.module.css │ │ │ │ └── FilterIcon.js │ │ │ ├── flag-icon │ │ │ │ └── FlagIcon.js │ │ │ ├── filter-dropdown │ │ │ │ ├── FilterDropdown.module.css │ │ │ │ └── FilterDropdown.js │ │ │ ├── index.js │ │ │ ├── with-fields │ │ │ │ └── withFields.js │ │ │ └── filters │ │ │ │ └── withFilters.js │ │ ├── scenes │ │ │ ├── scenes │ │ │ │ ├── login │ │ │ │ │ ├── components │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── login-form │ │ │ │ │ │ │ └── LoginForm.module.css │ │ │ │ │ ├── Login.module.css │ │ │ │ │ └── Login.js │ │ │ │ ├── index.js │ │ │ │ └── main │ │ │ │ │ ├── scenes │ │ │ │ │ ├── user-create-edit │ │ │ │ │ │ └── components │ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── challenge-create-edit │ │ │ │ │ │ └── components │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── challenge-form │ │ │ │ │ │ │ └── ChallengeForm.module.css │ │ │ │ │ ├── announcement-create-edit │ │ │ │ │ │ └── components │ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── challenges-table │ │ │ │ │ │ └── ChallengesTable.module.css │ │ │ │ │ ├── announcements-table │ │ │ │ │ │ └── AnnouncementsTable.module.css │ │ │ │ │ ├── dashboard │ │ │ │ │ │ └── Dashboard.js │ │ │ │ │ ├── users-table │ │ │ │ │ │ └── UsersTable.module.css │ │ │ │ │ ├── index.js │ │ │ │ │ └── files-tree │ │ │ │ │ │ └── FilesTree.js │ │ │ │ │ ├── components │ │ │ │ │ ├── index.js │ │ │ │ │ ├── sidebar │ │ │ │ │ │ ├── Sidebar.module.css │ │ │ │ │ │ └── Sidebar.js │ │ │ │ │ └── nav │ │ │ │ │ │ └── Nav.js │ │ │ │ │ └── Main.module.css │ │ │ └── App.js │ │ ├── index.html │ │ ├── models │ │ │ ├── index.js │ │ │ ├── game.js │ │ │ ├── auth.js │ │ │ ├── files.js │ │ │ ├── announcements.js │ │ │ ├── challenges.js │ │ │ └── users.js │ │ ├── utils │ │ │ ├── api.js │ │ │ ├── object.js │ │ │ ├── form.js │ │ │ └── rematch-api.js │ │ ├── index.css │ │ ├── index.js │ │ └── images │ │ │ ├── logo-small.svg │ │ │ ├── logo-big.svg │ │ │ └── logo-big-dark.svg │ ├── .gitignore │ ├── .eslintrc │ └── package.json └── public │ ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html │ ├── src │ ├── utils │ │ ├── object.js │ │ ├── date.js │ │ ├── api.js │ │ ├── form.js │ │ └── rematch-api.js │ ├── scenes │ │ ├── components │ │ │ ├── index.js │ │ │ ├── RequireAuth.js │ │ │ ├── Nav.js │ │ │ └── UserStats.js │ │ └── scenes │ │ │ ├── index.js │ │ │ ├── auth │ │ │ ├── Logout.js │ │ │ ├── Activate.js │ │ │ ├── Auth.js │ │ │ ├── ResetPassword.js │ │ │ ├── SendToken.js │ │ │ ├── SendTokenForm.js │ │ │ ├── ResetPasswordForm.js │ │ │ ├── Login.js │ │ │ ├── Signup.js │ │ │ └── LoginForm.js │ │ │ ├── rules │ │ │ └── Rules.js │ │ │ ├── challenge │ │ │ └── FlagForm.js │ │ │ ├── news │ │ │ └── Countdown.js │ │ │ ├── scoreboard │ │ │ └── Scoreboard.js │ │ │ └── challenges │ │ │ └── Challenges.js │ ├── components │ │ ├── spinner │ │ │ └── Spinner.js │ │ ├── divider │ │ │ └── Divider.js │ │ ├── not-found │ │ │ └── NotFound.js │ │ ├── index.js │ │ ├── window │ │ │ └── Window.js │ │ ├── loading │ │ │ └── Loading.js │ │ ├── page │ │ │ └── Page.js │ │ ├── button │ │ │ └── Button.js │ │ ├── form-item │ │ │ └── FormItem.js │ │ └── layout │ │ │ └── Layout.js │ ├── models │ │ ├── index.js │ │ ├── contest.js │ │ ├── scores.js │ │ ├── user.js │ │ ├── challenges.js │ │ ├── news.js │ │ └── auth.js │ └── index.js │ ├── .gitignore │ ├── package.json │ └── README.md ├── .dockerignore ├── models ├── fixtures │ ├── likes.yml │ ├── announcements.yml │ ├── tokens.yml │ ├── solutions.yml │ ├── challenges.yml │ └── users.yml ├── utils.go ├── models.go ├── solutions.go ├── migrations │ ├── migrations.go │ └── 1489143364_initial_schema.down.sql ├── likes.go ├── solutions_test.go ├── likes_test.go ├── models_test.go ├── tokens.go ├── announcements_test.go └── scores.go ├── main.go ├── controllers ├── schemas │ ├── users │ │ └── get.json │ ├── Users.json │ ├── Scores.json │ ├── admin │ │ ├── challenges │ │ │ ├── getOne.json │ │ │ ├── getAll.json │ │ │ ├── update.json │ │ │ └── create.json │ │ ├── users │ │ │ ├── getAll.json │ │ │ ├── getOne.json │ │ │ ├── update.json │ │ │ └── create.json │ │ ├── auth │ │ │ └── login.json │ │ └── announcements │ │ │ ├── create.json │ │ │ └── update.json │ ├── Announcements.json │ ├── Challenges.json │ ├── Solution.json │ ├── AdminAuthLogin.json │ ├── UserSolutionsCreate.json │ ├── AuthActivate.json │ ├── ChallengeUser.json │ ├── AuthLogin.json │ ├── AuthResetPassword.json │ ├── UserPublic.json │ ├── ChallengeE.json │ ├── ChallengeMeta.json │ ├── AuthSendToken.json │ ├── Score.json │ ├── GameInfo.json │ ├── AuthRegister.json │ ├── AdminUsersCreate.json │ ├── Error.json │ ├── Announcement.json │ ├── User.json │ ├── ScoresCTFTime.json │ └── Challenge.json ├── admin_game.go ├── game_test.go ├── announcements_test.go ├── params.go ├── validations.go ├── scores_test.go ├── pagination.go ├── file.go ├── utils.go ├── game.go ├── challenges_test.go ├── announcements.go ├── admin_files.go ├── admin_auth.go ├── challenges.go ├── scores.go ├── admin_announcements.go ├── middlewares.go └── error.go ├── README.md ├── internal ├── crypto │ ├── crypto.go │ ├── password.go │ └── flag.go └── mailer │ └── mock │ └── Sender.go ├── middlewares ├── schemacheck │ ├── error.go │ ├── options.go │ └── handler.go ├── csrf │ ├── context.go │ ├── token.go │ ├── options.go │ └── handler.go ├── cntcheck │ ├── options.go │ └── handler.go ├── timecheck │ ├── options.go │ └── handler.go └── recaptcha │ ├── handler.go │ ├── options.go │ └── check.go ├── .gitignore ├── .github └── workflows │ └── build.yml ├── Dockerfile ├── config └── helpers.go ├── Makefile ├── templates └── email │ └── ext │ ├── reset.tmpl │ └── activate.tmpl └── go.mod /web/admin/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctf-zone/ctfzone/HEAD/web/admin/public/favicon.ico -------------------------------------------------------------------------------- /web/public/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctf-zone/ctfzone/HEAD/web/public/public/favicon.ico -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | files/ 2 | web/admin/dist/ 3 | web/public/dist/ 4 | web/admin/node_modules/ 5 | web/public/node_modules/ 6 | -------------------------------------------------------------------------------- /web/public/src/utils/object.js: -------------------------------------------------------------------------------- 1 | export const isEmpty = (obj) => Object.keys(obj).length === 0 && obj.constructor === Object 2 | -------------------------------------------------------------------------------- /models/fixtures/likes.yml: -------------------------------------------------------------------------------- 1 | - user_id: 1 # LC↯BC 2 | challenge_id: 1 # web-100 3 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ctf-zone/ctfzone/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /web/admin/src/components/markdown-editor/MarkdownEditor.css: -------------------------------------------------------------------------------- 1 | @import '~codemirror/lib/codemirror.css'; 2 | @import '~codemirror/theme/idea.css'; 3 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/login/components/index.js: -------------------------------------------------------------------------------- 1 | import LoginForm from './login-form/LoginForm' 2 | 3 | export { 4 | LoginForm, 5 | } 6 | -------------------------------------------------------------------------------- /web/admin/src/components/pagination/Pagination.module.css: -------------------------------------------------------------------------------- 1 | .pagination { 2 | display: flex; 3 | justify-content: center; 4 | padding: 20px; 5 | } 6 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/index.js: -------------------------------------------------------------------------------- 1 | import Login from './login/Login' 2 | import Main from './main/Main' 3 | 4 | export { 5 | Login, 6 | Main, 7 | } 8 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/user-create-edit/components/index.js: -------------------------------------------------------------------------------- 1 | import UserForm from "./user-form/UserForm"; 2 | 3 | export { UserForm }; 4 | -------------------------------------------------------------------------------- /controllers/schemas/users/get.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Users", 3 | "description": "List of users", 4 | "type": "object", 5 | "$ref": "file:///User.json" 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CTFZone 2 | 3 | [![](https://img.shields.io/travis/com/ctf-zone/ctfzone.svg?style=flat)](https://travis-ci.com/ctf-zone/ctfzone) 4 | 5 | Jeopardy CTF platform. 6 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/components/index.js: -------------------------------------------------------------------------------- 1 | import Nav from './nav/Nav' 2 | import Sidebar from './sidebar/Sidebar' 3 | 4 | export { 5 | Nav, 6 | Sidebar, 7 | } 8 | -------------------------------------------------------------------------------- /controllers/schemas/Users.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Users", 3 | "description": "List of users", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///User.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/public/src/utils/date.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | 4 | dayjs.extend(relativeTime); 5 | 6 | export default dayjs; 7 | -------------------------------------------------------------------------------- /controllers/schemas/Scores.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Scores", 3 | "description": "List of scores", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///Score.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /controllers/schemas/admin/challenges/getOne.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminChallengesGetOne", 3 | "description": "Get one challenge response", 4 | "$ref": "file:///ChallengeE.json" 5 | } 6 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/challenge-create-edit/components/index.js: -------------------------------------------------------------------------------- 1 | import ChallengeForm from './challenge-form/ChallengeForm' 2 | 3 | export { 4 | ChallengeForm, 5 | } 6 | -------------------------------------------------------------------------------- /internal/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrInvalidHash = errors.New("Invalid hash format") 7 | ErrMismatch = errors.New("Hash mismatch") 8 | ) 9 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/announcement-create-edit/components/index.js: -------------------------------------------------------------------------------- 1 | import AnnouncementForm from './announcement-form/AnnouncementForm' 2 | 3 | export { 4 | AnnouncementForm, 5 | } 6 | -------------------------------------------------------------------------------- /web/public/src/scenes/components/index.js: -------------------------------------------------------------------------------- 1 | import Nav from './Nav'; 2 | import RequireAuth from './RequireAuth'; 3 | import UserStats from './UserStats'; 4 | 5 | export { Nav, RequireAuth, UserStats }; 6 | -------------------------------------------------------------------------------- /controllers/schemas/Announcements.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Announcements", 3 | "description": "Announcements", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///Announcement.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/admin/src/components/filter-icon/FilterIcon.module.css: -------------------------------------------------------------------------------- 1 | .active { 2 | color: var(--filter-color-active) !important; 3 | } 4 | 5 | .inactive { 6 | color: var(--filter-color-inactive) !important; 7 | } 8 | -------------------------------------------------------------------------------- /controllers/schemas/admin/users/getAll.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminsUsersGetAll", 3 | "description": "Get all users respones", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///User.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /controllers/schemas/admin/users/getOne.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminsUsersGetOne", 3 | "description": "Get one user respones", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///User.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /controllers/schemas/Challenges.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "ChallengesExt", 3 | "description": "List of challenges with metadata", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///ChallengeE.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /controllers/schemas/admin/challenges/getAll.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminChallengesGetAll", 3 | "description": "Get all challenges response", 4 | "type": "array", 5 | "items": { 6 | "$ref": "file:///ChallengeE.json" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/admin/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # production 8 | /build 9 | 10 | # cache 11 | /.cache 12 | .eslintcache 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | -------------------------------------------------------------------------------- /web/public/src/components/spinner/Spinner.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class Spinner extends Component { 4 | render() { 5 | return
; 6 | } 7 | } 8 | 9 | export default Spinner; 10 | -------------------------------------------------------------------------------- /models/utils.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func now() time.Time { 8 | // PostgreSQL currently can't store nanoseconds 9 | // https://github.com/lib/pq/issues/227 10 | return time.Now().UTC().Truncate(time.Microsecond) 11 | } 12 | -------------------------------------------------------------------------------- /web/public/src/components/divider/Divider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class Divider extends Component { 4 | render() { 5 | return ( 6 |
7 | ) 8 | } 9 | } 10 | 11 | export default Divider 12 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/login/components/login-form/LoginForm.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | padding: 10px; 6 | } 7 | 8 | .logo img { 9 | height: 100px; 10 | } 11 | 12 | .submit { 13 | width: 100%; 14 | } 15 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/Main.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | padding: 24px; 3 | } 4 | 5 | .body { 6 | padding: 10px; 7 | background-color: #fff; 8 | } 9 | 10 | .spin { 11 | height: 100vh; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/challenges-table/ChallengesTable.module.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | padding: 10px 0; 3 | } 4 | 5 | th, td { 6 | text-align: center !important; 7 | } 8 | 9 | .controlsLeft { 10 | text-align: left; 11 | } 12 | 13 | .controlsRight { 14 | text-align: right; 15 | } 16 | -------------------------------------------------------------------------------- /web/public/src/components/not-found/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class NotFound extends Component { 4 | render() { 5 | return ( 6 |
7 |

404 Page Not Found

8 |
9 | ) 10 | } 11 | } 12 | 13 | export default NotFound 14 | -------------------------------------------------------------------------------- /web/admin/src/components/flag-icon/FlagIcon.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import FlagIconFactory, { functions } from 'react-flag-icon-css' 3 | 4 | const FlagIcon = FlagIconFactory(React, { useCssModules: false }) 5 | 6 | export const countries = functions.countries.getCountries() 7 | export default FlagIcon 8 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/announcements-table/AnnouncementsTable.module.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | padding: 10px 0; 3 | } 4 | 5 | th, td { 6 | text-align: center !important; 7 | } 8 | 9 | .controlsLeft { 10 | text-align: left; 11 | } 12 | 13 | .controlsRight { 14 | text-align: right; 15 | } 16 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/dashboard/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class Dashboard extends Component { 4 | render() { 5 | return ( 6 |
7 |

Dashboard

8 |
9 | ) 10 | } 11 | } 12 | 13 | export default Dashboard 14 | -------------------------------------------------------------------------------- /controllers/schemas/Solution.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Solution", 3 | "description": "Solution model", 4 | "type": "object", 5 | "properties": { 6 | "user": { 7 | "$ref": "file:///UserPublic.json" 8 | }, 9 | "challenge": { 10 | "$ref": "file:///ChallengeExt.json" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/admin/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | M⭑CTF 2018 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /controllers/schemas/AdminAuthLogin.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminAuthLogin", 3 | "description": "AdminAuthLogin request schema", 4 | "type": "object", 5 | "properties": { 6 | "password": { 7 | "type": "string" 8 | } 9 | }, 10 | "additionalProperties": false, 11 | "required": [ 12 | "password" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /controllers/schemas/UserSolutionsCreate.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "UserSolutionsCreate", 3 | "description": "User solutions create request", 4 | "type": "object", 5 | "properties": { 6 | "flag": { 7 | "type": "string" 8 | } 9 | }, 10 | "additionalProperties": false, 11 | "required": [ 12 | "flag" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /controllers/schemas/admin/auth/login.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminAuthLogin", 3 | "description": "AdminAuthLogin request schema", 4 | "type": "object", 5 | "properties": { 6 | "password": { 7 | "type": "string" 8 | } 9 | }, 10 | "additionalProperties": false, 11 | "required": [ 12 | "password" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /web/public/src/models/index.js: -------------------------------------------------------------------------------- 1 | import auth from './auth' 2 | import challenges from './challenges' 3 | import news from './news' 4 | import user from './user' 5 | import scores from './scores' 6 | import contest from './contest' 7 | 8 | export default { 9 | auth, 10 | challenges, 11 | news, 12 | user, 13 | scores, 14 | contest, 15 | } 16 | -------------------------------------------------------------------------------- /controllers/schemas/AuthActivate.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AuthActivate", 3 | "description": "AuthActivate request schema", 4 | "type": "object", 5 | "properties": { 6 | "token": { 7 | "type": "string", 8 | "pattern": "^[0-9a-f]{64}$" 9 | } 10 | }, 11 | "additionalProperties": false, 12 | "required": [ 13 | "token" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /web/admin/src/components/markdown-editor/MarkdownEditor.module.css: -------------------------------------------------------------------------------- 1 | .edit { 2 | border: 1px solid var(--field-border-color); 3 | border-radius: 4px; 4 | line-height: 1.5 !important; 5 | } 6 | 7 | .preview { 8 | border: 1px solid var(--field-border-color); 9 | border-radius: 4px; 10 | height: 302px; 11 | overflow-y: scroll; 12 | padding: 24px; 13 | } 14 | -------------------------------------------------------------------------------- /web/admin/src/models/index.js: -------------------------------------------------------------------------------- 1 | import auth from './auth' 2 | import challenges from './challenges' 3 | import users from './users' 4 | import game from './game' 5 | import files from './files' 6 | import announcements from './announcements' 7 | 8 | export default { 9 | auth, 10 | challenges, 11 | users, 12 | game, 13 | files, 14 | announcements, 15 | } 16 | -------------------------------------------------------------------------------- /web/public/src/scenes/scenes/index.js: -------------------------------------------------------------------------------- 1 | import Auth from './auth/Auth'; 2 | import Challenge from './challenge/Challenge'; 3 | import Challenges from './challenges/Challenges'; 4 | import News from './news/News'; 5 | import Rules from './rules/Rules'; 6 | import Scoreboard from './scoreboard/Scoreboard'; 7 | 8 | export { Auth, Challenge, Challenges, News, Rules, Scoreboard }; 9 | -------------------------------------------------------------------------------- /middlewares/schemacheck/error.go: -------------------------------------------------------------------------------- 1 | package schemacheck 2 | 3 | type FieldError struct { 4 | Field string 5 | Msg string 6 | } 7 | 8 | type Error struct { 9 | Msg string 10 | Errs []*FieldError 11 | } 12 | 13 | func (e *Error) Error() string { 14 | s := e.Msg + "; " 15 | for _, e := range e.Errs { 16 | s += e.Field + ": " + e.Msg + ";" 17 | } 18 | return s 19 | } 20 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/login/Login.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | height: 100vh; 3 | background-color: var(--light-background); 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | } 8 | 9 | .form { 10 | width: 300px; 11 | padding: 20px; 12 | box-shadow: 0 0 100px rgba(0, 0, 0, .08); 13 | background-color: var(--dark-background); 14 | } 15 | -------------------------------------------------------------------------------- /controllers/admin_game.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/ctf-zone/ctfzone/config" 7 | ) 8 | 9 | func AdminGameInfo(c *config.Game) http.HandlerFunc { 10 | return func(w http.ResponseWriter, r *http.Request) { 11 | if err := responseJSON(w, c); err != nil { 12 | handleError(w, r, ErrInternal.SetError(err)) 13 | return 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /web/admin/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "CTFZone Admin", 3 | "name": "CTFZOne Admin Panel", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /web/public/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /controllers/game_test.go: -------------------------------------------------------------------------------- 1 | package controllers_test 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGameInfo(t *testing.T) { 8 | setup(t) 9 | defer teardown(t) 10 | 11 | e := heDefault(t) 12 | e = heHost(e, "ctfzone.test") 13 | e = heCSRF(e) 14 | 15 | res := e.GET("/api/game"). 16 | Expect(). 17 | Status(200) 18 | 19 | checkJSONSchema(t, "GameInfo.json", res.Body().Raw()) 20 | } 21 | -------------------------------------------------------------------------------- /web/admin/src/models/game.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api' 2 | 3 | export default { 4 | state: {}, 5 | reducers: { 6 | set: (state, payload) => { 7 | return { ...state, ...payload } 8 | }, 9 | }, 10 | effects: { 11 | async get() { 12 | const response = await api.get('game') 13 | const game = response.data 14 | this.set(game) 15 | return game 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /controllers/schemas/ChallengeUser.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "ChallengeUser", 3 | "description": "Challenge metadata for current user", 4 | "type": "object", 5 | "properties": { 6 | "isSolved": { 7 | "type": "boolean" 8 | }, 9 | "isLiked": { 10 | "type": "boolean" 11 | } 12 | }, 13 | "additionalProperties": false, 14 | "required": [ 15 | "isSolved", 16 | "isLiked" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /controllers/schemas/AuthLogin.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AuthLogin", 3 | "description": "AuthLogin request schema", 4 | "type": "object", 5 | "properties": { 6 | "email": { 7 | "type": "string", 8 | "format": "email" 9 | }, 10 | "password": { 11 | "type": "string" 12 | } 13 | }, 14 | "additionalProperties": false, 15 | "required": [ 16 | "email", 17 | "password" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /controllers/announcements_test.go: -------------------------------------------------------------------------------- 1 | package controllers_test 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAnnouncementsList_Success(t *testing.T) { 8 | setup(t) 9 | defer teardown(t) 10 | 11 | e := heDefault(t) 12 | e = heHost(e, "ctfzone.test") 13 | e = heCSRF(e) 14 | 15 | res := e.GET("/api/announcements"). 16 | Expect(). 17 | Status(200) 18 | 19 | checkJSONSchema(t, "Announcements.json", res.Body().Raw()) 20 | } 21 | -------------------------------------------------------------------------------- /web/admin/src/components/filter-dropdown/FilterDropdown.module.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | border-radius: 6px; 3 | background: #fff; 4 | box-shadow: 0 1px 6px rgba(0, 0, 0, .2); 5 | } 6 | 7 | .container { 8 | padding: 15px; 9 | } 10 | 11 | .buttons { 12 | padding: 7px 15px; 13 | overflow: hidden; 14 | border-top: 1px solid #e8e8e8; 15 | } 16 | 17 | .ok { 18 | float: left; 19 | } 20 | 21 | .reset { 22 | float: right; 23 | } 24 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/users-table/UsersTable.module.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | padding: 10px 0; 3 | } 4 | 5 | th, 6 | td { 7 | text-align: center !important; 8 | } 9 | 10 | .upload { 11 | margin-left: 10px; 12 | } 13 | 14 | .controlsLeft { 15 | text-align: left; 16 | } 17 | 18 | .controlsRight { 19 | text-align: right; 20 | } 21 | 22 | .flag { 23 | border: 1px solid #d9d9d9; 24 | background-size: cover; 25 | } 26 | -------------------------------------------------------------------------------- /controllers/schemas/admin/announcements/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AnnouncementCreateRequest", 3 | "type": "object", 4 | "properties": { 5 | "title": { 6 | "type": "string" 7 | }, 8 | "body": { 9 | "type": "string" 10 | }, 11 | "challengeId": { 12 | "type": "integer", 13 | "minimum": 1 14 | } 15 | }, 16 | "additionalProperties": false, 17 | "required": [ 18 | "title" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /controllers/schemas/admin/announcements/update.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AnnouncementUpdateRequest", 3 | "type": "object", 4 | "properties": { 5 | "title": { 6 | "type": "string" 7 | }, 8 | "body": { 9 | "type": "string" 10 | }, 11 | "challengeId": { 12 | "type": "integer", 13 | "minimum": 1 14 | } 15 | }, 16 | "additionalProperties": false, 17 | "required": [ 18 | "title" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /web/public/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | .eslintcache 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /web/public/src/models/contest.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api'; 2 | 3 | export default { 4 | state: { 5 | status: {} 6 | }, 7 | reducers: { 8 | set: (state, payload) => { 9 | return { ...state, ...payload }; 10 | } 11 | }, 12 | effects: { 13 | async getStatus() { 14 | const response = await api.get('/game'); 15 | this.set({ status: response.data }); 16 | return response.data; 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /controllers/schemas/AuthResetPassword.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AuthResetPassword", 3 | "description": "AuthResetPassword request schema", 4 | "type": "object", 5 | "properties": { 6 | "token": { 7 | "type": "string", 8 | "pattern": "^[0-9a-f]{64}$" 9 | }, 10 | "password": { 11 | "type": "string" 12 | } 13 | }, 14 | "additionalProperties": false, 15 | "required": [ 16 | "token", 17 | "password" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/login/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | import { LoginForm } from './components' 4 | 5 | import styles from './Login.module.css' 6 | 7 | class Login extends Component { 8 | render() { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 | ) 16 | } 17 | } 18 | 19 | export default Login 20 | -------------------------------------------------------------------------------- /web/public/src/models/scores.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api'; 2 | 3 | export default { 4 | state: { 5 | items: [] 6 | }, 7 | reducers: { 8 | set: (state, payload) => { 9 | return { ...state, ...payload }; 10 | } 11 | }, 12 | effects: { 13 | async list() { 14 | const response = await api.get('/scores'); 15 | const items = response.data; 16 | this.set({ items }); 17 | return items; 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /controllers/schemas/UserPublic.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "UserPublic", 3 | "description": "User public model", 4 | "type": "object", 5 | "properties": { 6 | "id": { 7 | "type": "integer", 8 | "minimum": 1 9 | }, 10 | "name": { 11 | "type": "string" 12 | }, 13 | "extra": { 14 | "type": "object" 15 | } 16 | }, 17 | "additionalProperties": false, 18 | "required": [ 19 | "id", 20 | "name", 21 | "extra" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /web/public/src/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default axios.create({ 4 | baseURL: process.env.API_URL || '/api', 5 | xsrfCookieName: 'csrf-token', 6 | xsrfHeaderName: 'X-CSRF-Token', 7 | withCredentials: true 8 | }); 9 | 10 | export class ApiError extends Error { 11 | constructor(status, message, errors) { 12 | super('ApiError'); 13 | 14 | this.status = status; 15 | this.message = message; 16 | this.errors = errors; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /controllers/schemas/ChallengeE.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "ChallengeE", 3 | "description": "Challenge Extended model", 4 | "type": "object", 5 | "properties": { 6 | "challenge": { 7 | "$ref": "file:///Challenge.json" 8 | }, 9 | "meta": { 10 | "$ref": "file:///ChallengeMeta.json" 11 | }, 12 | "user": { 13 | "$ref": "file:///ChallengeUser.json" 14 | } 15 | }, 16 | "additionalProperties": false, 17 | "required": [ 18 | "challenge" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /controllers/schemas/ChallengeMeta.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "ChallengeMeta", 3 | "description": "Challenge metadata", 4 | "type": "object", 5 | "properties": { 6 | "solutionsCount": { 7 | "type": "number" 8 | }, 9 | "likesCount": { 10 | "type": "number" 11 | }, 12 | "hintsCount": { 13 | "type": "number" 14 | } 15 | }, 16 | "additionalProperties": false, 17 | "required": [ 18 | "solutionsCount", 19 | "likesCount", 20 | "hintsCount" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /middlewares/csrf/context.go: -------------------------------------------------------------------------------- 1 | package csrf 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "net/http" 7 | ) 8 | 9 | type contextKey int 10 | 11 | const ( 12 | tokenKey contextKey = iota 13 | ) 14 | 15 | func Token(r *http.Request) string { 16 | return r.Context().Value(tokenKey).(string) 17 | } 18 | 19 | func addTokenToRequest(r *http.Request, token []byte) *http.Request { 20 | return r.WithContext(context.WithValue(r.Context(), tokenKey, base64.URLEncoding.EncodeToString(token))) 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ========== 2 | # = Golang = 3 | # ========== 4 | 5 | # Test binary, build with `go test -c` 6 | *.test 7 | 8 | # Output of the go coverage tool, specifically when used with LiteIDE 9 | *.out 10 | 11 | # ======== 12 | # = Misc = 13 | # ======== 14 | 15 | *.DS_Store 16 | 17 | # ======= 18 | # = App = 19 | # ======= 20 | 21 | # Env 22 | .env 23 | 24 | # TLS test file 25 | server.key 26 | server.crt 27 | 28 | # Binary 29 | ctfzone 30 | 31 | # Image 32 | ctfzone.tgz 33 | 34 | # Files 35 | files/ 36 | -------------------------------------------------------------------------------- /controllers/schemas/AuthSendToken.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AuthSendToken", 3 | "description": "AuthSendToken request schema", 4 | "type": "object", 5 | "properties": { 6 | "email": { 7 | "type": "string", 8 | "format": "email" 9 | }, 10 | "type": { 11 | "type": "string", 12 | "enum": [ 13 | "activate", 14 | "reset" 15 | ] 16 | } 17 | }, 18 | "additionalProperties": false, 19 | "required": [ 20 | "email", 21 | "type" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | _ "github.com/lib/pq" 6 | ) 7 | 8 | type Repository struct { 9 | db *sqlx.DB 10 | } 11 | 12 | func New(dsn string) (*Repository, error) { 13 | 14 | db, err := sqlx.Connect("postgres", dsn) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return &Repository{db}, nil 20 | } 21 | 22 | func (r *Repository) Close() error { 23 | return r.db.Close() 24 | } 25 | 26 | func (r *Repository) EnableLogging() { 27 | // TODO 28 | } 29 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/challenge-create-edit/components/challenge-form/ChallengeForm.module.css: -------------------------------------------------------------------------------- 1 | @import '~codemirror/lib/codemirror.css'; 2 | @import '~codemirror/theme/idea.css'; 3 | 4 | .description-edit { 5 | border: 1px solid var(--field-border-color); 6 | border-radius: 4px; 7 | line-height: 1.5 !important; 8 | } 9 | 10 | .description-preview { 11 | border: 1px solid var(--field-border-color); 12 | border-radius: 4px; 13 | height: 302px; 14 | overflow-y: scroll; 15 | padding: 24px; 16 | } 17 | -------------------------------------------------------------------------------- /web/admin/src/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export default axios.create({ 4 | baseURL: process.env.API_URL || '/api', 5 | xsrfCookieName: 'csrf-token', 6 | xsrfHeaderName: 'X-CSRF-Token', 7 | withCredentials: true, 8 | }) 9 | 10 | export class ApiError extends Error { 11 | constructor(status, message, errors) { 12 | super('ApiError') 13 | Error.captureStackTrace(this, ApiError) 14 | 15 | this.status = status 16 | this.message = message 17 | this.errors = errors 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/admin/src/scenes/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Switch, Route, withRouter } from 'react-router-dom' 3 | import { connect } from 'react-redux' 4 | 5 | import * as scenes from './scenes' 6 | 7 | class App extends Component { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | } 17 | 18 | export default withRouter(connect()(App)) 19 | -------------------------------------------------------------------------------- /controllers/schemas/Score.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Score", 3 | "description": "Score model", 4 | "type": "object", 5 | "properties": { 6 | "user": { 7 | "$ref": "file:///UserPublic.json" 8 | }, 9 | "score": { 10 | "type": "number" 11 | }, 12 | "rank": { 13 | "type": "integer" 14 | }, 15 | "updatedAt": { 16 | "type": "string", 17 | "format": "date-time" 18 | } 19 | }, 20 | "additionalProperties": false, 21 | "required": [ 22 | "user", 23 | "score" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /middlewares/cntcheck/options.go: -------------------------------------------------------------------------------- 1 | package cntcheck 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | var defaultOptions = &options{ 8 | errorFunc: defaultErrorFunc, 9 | } 10 | 11 | type options struct { 12 | errorFunc func(http.ResponseWriter, *http.Request, error) 13 | } 14 | 15 | // Option defines the functional arguments for configuring the middleware. 16 | type Option func(*options) 17 | 18 | func ErrorFunc(f func(http.ResponseWriter, *http.Request, error)) Option { 19 | return func(opts *options) { 20 | opts.errorFunc = f 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/admin/src/models/auth.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api' 2 | 3 | export default { 4 | state: false, 5 | reducers: { 6 | set: (state, payload) => { 7 | return payload 8 | }, 9 | }, 10 | effects: { 11 | async login(data) { 12 | await api.post('auth/login', data) 13 | this.set(true) 14 | }, 15 | async check() { 16 | await api.get('auth/check') 17 | this.set(true) 18 | }, 19 | async logout() { 20 | await api.post('auth/logout') 21 | this.set(false) 22 | }, 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /controllers/params.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/gorilla/schema" 8 | ) 9 | 10 | var decoder = schema.NewDecoder() 11 | 12 | func init() { 13 | decoder.RegisterConverter(map[string]interface{}{}, func(input string) reflect.Value { 14 | m := make(map[string]interface{}) 15 | for _, pair := range strings.Split(input, ";") { 16 | parts := strings.Split(pair, ":") 17 | if len(parts) == 2 { 18 | m[parts[0]] = parts[1] 19 | } 20 | } 21 | return reflect.ValueOf(m) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /controllers/schemas/GameInfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "GameInfo", 3 | "description": "Game information", 4 | "type": "object", 5 | "properties": { 6 | "status": { 7 | "type": "string", 8 | "enum": ["countdown", "started", "ended"] 9 | }, 10 | "start": { 11 | "type": "string", 12 | "format": "date-time" 13 | }, 14 | "end": { 15 | "type": "string", 16 | "format": "date-time" 17 | } 18 | }, 19 | "additionalProperties": false, 20 | "required": [ 21 | "status", 22 | "start", 23 | "end" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /web/public/src/components/index.js: -------------------------------------------------------------------------------- 1 | import Button from './button/Button'; 2 | import FormItem from './form-item/FormItem'; 3 | import Layout from './layout/Layout'; 4 | import Page from './page/Page'; 5 | import Window from './window/Window'; 6 | import Spinner from './spinner/Spinner'; 7 | import Loading from './loading/Loading'; 8 | import Divider from './divider/Divider'; 9 | import NotFound from './not-found/NotFound'; 10 | 11 | export { 12 | Button, 13 | FormItem, 14 | Layout, 15 | Page, 16 | Window, 17 | Spinner, 18 | Loading, 19 | Divider, 20 | NotFound 21 | }; 22 | -------------------------------------------------------------------------------- /web/public/src/components/window/Window.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Window extends Component { 5 | static propTypes = { 6 | title: PropTypes.string, 7 | type: PropTypes.string, 8 | children: PropTypes.node 9 | }; 10 | 11 | render() { 12 | return ( 13 |
14 |

{this.props.title}

15 | {this.props.children} 16 |
17 | ); 18 | } 19 | } 20 | 21 | export default Window; 22 | -------------------------------------------------------------------------------- /web/public/src/models/user.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api'; 2 | 3 | export default { 4 | state: { 5 | stats: {} 6 | }, 7 | reducers: { 8 | set: (state, payload) => { 9 | return { ...state, ...payload }; 10 | } 11 | }, 12 | effects: { 13 | async getStats() { 14 | const response = await api.get('/user/stats'); 15 | const data = response.data; 16 | this.set({ stats: data }); 17 | return data; 18 | }, 19 | async createSolution({ id, flag }) { 20 | await api.post(`/user/solutions/${id}`, { flag }); 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /models/fixtures/announcements.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | title: Test 3 | body: This is announcement 4 | challenge_id: null 5 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 6 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 7 | 8 | - id: 2 9 | title: Latest announcement 10 | body: This is latest announcement 11 | challenge_id: null 12 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 13 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 14 | 15 | - id: 3 16 | title: Hint 17 | body: This is hint 18 | challenge_id: 3 19 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 20 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 21 | -------------------------------------------------------------------------------- /middlewares/timecheck/options.go: -------------------------------------------------------------------------------- 1 | package timecheck 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | var defaultOptions = &options{ 9 | errorFunc: defaultErrorFunc, 10 | } 11 | 12 | type options struct { 13 | start time.Time 14 | end time.Time 15 | errorFunc func(http.ResponseWriter, *http.Request, error) 16 | } 17 | 18 | // Option defines the functional arguments for configuring the middleware. 19 | type Option func(*options) 20 | 21 | func ErrorFunc(f func(http.ResponseWriter, *http.Request, error)) Option { 22 | return func(opts *options) { 23 | opts.errorFunc = f 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/admin/src/utils/object.js: -------------------------------------------------------------------------------- 1 | const setPath = ([key, ...next], value, obj) => { 2 | if (next.length === 0) { 3 | return { ...obj, [key]: value } 4 | } 5 | return { ...obj, [key]: setPath(next, value, obj[key]) } 6 | } 7 | 8 | export const set = (path, value, obj) => setPath(path.split('.'), value, obj) 9 | 10 | const unsetPath = ([key, ...next], obj) => { 11 | const { [key]: value, ...rest } = obj 12 | if (next.length === 0) { 13 | return rest 14 | } 15 | return { ...rest, [key]: unsetPath(next, value) } 16 | } 17 | 18 | export const unset = (path, obj) => unsetPath(path.split('.'), obj) 19 | -------------------------------------------------------------------------------- /internal/crypto/password.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import "golang.org/x/crypto/bcrypt" 4 | 5 | // HashPassword hashes given passwords. 6 | func HashPassword(password string) (string, error) { 7 | 8 | hash, err := bcrypt.GenerateFromPassword( 9 | []byte(password), 10 | bcrypt.DefaultCost, 11 | ) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | return string(hash), nil 17 | } 18 | 19 | // CheckPassword compares given password and hash. 20 | func CheckPassword(hash, password string) error { 21 | return bcrypt.CompareHashAndPassword( 22 | []byte(hash), 23 | []byte(password), 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /controllers/schemas/AuthRegister.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AuthRegister", 3 | "description": "AuthRegister request schema", 4 | "type": "object", 5 | "properties": { 6 | "name": { 7 | "type": "string", 8 | "minLength": 3 9 | }, 10 | "email": { 11 | "type": "string", 12 | "format": "email" 13 | }, 14 | "password": { 15 | "type": "string", 16 | "minLength": 8 17 | }, 18 | "extra": { 19 | "type": "object" 20 | } 21 | }, 22 | "additionalProperties": false, 23 | "required": [ 24 | "name", 25 | "email", 26 | "password" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /controllers/validations.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import "github.com/ctf-zone/ctfzone/models" 4 | 5 | func userValidate(db *models.Repository, u *models.User) (bool, map[string][]string) { 6 | 7 | errs := make(map[string][]string) 8 | 9 | if _, err := db.UsersOneByName(u.Name); err == nil { 10 | errs["name"] = []string{"User with such name already exists"} 11 | } 12 | 13 | if _, err := db.UsersOneByEmail(u.Email); err == nil { 14 | errs["email"] = []string{"User with such email already exists"} 15 | } 16 | 17 | if len(errs) > 0 { 18 | return false, errs 19 | } 20 | 21 | return true, nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/crypto/flag.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/subtle" 6 | "encoding/hex" 7 | "fmt" 8 | ) 9 | 10 | func CheckFlag(hash, flag string) error { 11 | expectedHash, err := hex.DecodeString(hash) 12 | if err != nil { 13 | return ErrInvalidHash 14 | } 15 | 16 | actualHash := sha256.Sum256([]byte(flag)) 17 | 18 | if subtle.ConstantTimeCompare( 19 | expectedHash, 20 | actualHash[:], 21 | ) != 1 { 22 | return ErrMismatch 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func HashFlag(flag string) string { 29 | return fmt.Sprintf("%x", sha256.Sum256([]byte(flag))) 30 | } 31 | -------------------------------------------------------------------------------- /web/public/src/components/loading/Loading.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Spinner from '../spinner/Spinner' 5 | 6 | class Loading extends Component { 7 | 8 | static propTypes = { 9 | children: PropTypes.node, 10 | loading: PropTypes.bool, 11 | } 12 | 13 | render() { 14 | const { loading, children } = this.props 15 | 16 | if (loading) { 17 | return ( 18 |
19 | 20 |
21 | ) 22 | } 23 | 24 | return children 25 | } 26 | } 27 | 28 | export default Loading 29 | -------------------------------------------------------------------------------- /internal/mailer/mock/Sender.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0 2 | package mailer_mock 3 | 4 | import mock "github.com/stretchr/testify/mock" 5 | 6 | // Sender is an autogenerated mock type for the Sender type 7 | type Sender struct { 8 | mock.Mock 9 | } 10 | 11 | // Send provides a mock function with given fields: template, to, data 12 | func (_m *Sender) Send(template string, to string, data interface{}) error { 13 | ret := _m.Called(template, to, data) 14 | 15 | var r0 error 16 | if rf, ok := ret.Get(0).(func(string, string, interface{}) error); ok { 17 | r0 = rf(template, to, data) 18 | } else { 19 | r0 = ret.Error(0) 20 | } 21 | 22 | return r0 23 | } 24 | -------------------------------------------------------------------------------- /web/admin/src/components/filter-icon/FilterIcon.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Icon } from 'antd' 4 | 5 | import styles from './FilterIcon.module.css' 6 | 7 | class FilterIcon extends Component { 8 | static propTypes = { 9 | isFiltered: PropTypes.bool, 10 | onClick: PropTypes.func, 11 | } 12 | 13 | render() { 14 | const { isFiltered, ...rest } = this.props 15 | 16 | return ( 17 | 22 | ) 23 | } 24 | } 25 | 26 | export default FilterIcon 27 | -------------------------------------------------------------------------------- /web/public/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import { init } from '@rematch/core'; 5 | import apiPlugin from './utils/rematch-api'; 6 | import { Provider } from 'react-redux'; 7 | 8 | import { BrowserRouter as Router } from 'react-router-dom'; 9 | 10 | import models from './models'; 11 | import Main from './scenes/Main'; 12 | 13 | import './index.scss'; 14 | 15 | const store = init({ 16 | models, 17 | plugins: [apiPlugin({})] 18 | }); 19 | 20 | ReactDOM.render( 21 | 22 | 23 |
24 | 25 | , 26 | document.getElementById('root') 27 | ); 28 | -------------------------------------------------------------------------------- /web/public/src/components/page/Page.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Divider from '../divider/Divider' 5 | 6 | class Page extends Component { 7 | 8 | static propTypes = { 9 | children: PropTypes.node, 10 | type: PropTypes.string, 11 | title: PropTypes.string, 12 | } 13 | 14 | render() { 15 | const { type, title, children } = this.props 16 | return ( 17 |
18 |

{title}

19 | 20 | {children} 21 |
22 | ) 23 | } 24 | } 25 | 26 | export default Page 27 | -------------------------------------------------------------------------------- /controllers/schemas/admin/users/update.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminUserUpdate", 3 | "description": "User update request", 4 | "type": "object", 5 | "properties": { 6 | "name": { 7 | "type": "string" 8 | }, 9 | "email": { 10 | "type": "string", 11 | "format": "email" 12 | }, 13 | "password": { 14 | "type": "string", 15 | "minLength": 8 16 | }, 17 | "isActivated": { 18 | "type": "boolean" 19 | }, 20 | "extra": { 21 | "type": "object" 22 | } 23 | }, 24 | "additionalProperties": false, 25 | "required": [ 26 | "name", 27 | "email", 28 | "isActivated", 29 | "extra" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /controllers/schemas/AdminUsersCreate.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminUsersCreate", 3 | "description": "AdminUsersCreate request", 4 | "type": "object", 5 | "properties": { 6 | "name": { 7 | "type": "string" 8 | }, 9 | "email": { 10 | "type": "string", 11 | "format": "email" 12 | }, 13 | "password": { 14 | "type": "string", 15 | "minLength": 8 16 | }, 17 | "isActivated": { 18 | "type": "boolean" 19 | }, 20 | "extra": { 21 | "type": "object" 22 | } 23 | }, 24 | "additionalProperties": false, 25 | "required": [ 26 | "name", 27 | "email", 28 | "password", 29 | "isActivated" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /web/public/src/utils/form.js: -------------------------------------------------------------------------------- 1 | import { createFormField } from 'rc-form'; 2 | 3 | export const addErrors = (fields, errors) => { 4 | return Object.assign( 5 | fields, 6 | ...Object.keys(errors).map(field => ({ 7 | [field]: createFormField({ 8 | ...fields[field], 9 | errors: errors[field].map(e => new Error(e)) 10 | }) 11 | })) 12 | ); 13 | }; 14 | 15 | export const mapPropsToFields = ({ fields }) => { 16 | return Object.assign( 17 | {}, 18 | ...Object.keys(fields || {}).map(field => { 19 | return { 20 | [field]: createFormField({ 21 | ...fields[field] 22 | }) 23 | }; 24 | }) 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /controllers/scores_test.go: -------------------------------------------------------------------------------- 1 | package controllers_test 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestScoresList(t *testing.T) { 8 | setup(t) 9 | defer teardown(t) 10 | 11 | e := heDefault(t) 12 | e = heHost(e, "ctfzone.test") 13 | 14 | res := e.GET("/api/scores"). 15 | Expect(). 16 | Status(200) 17 | 18 | checkJSONSchema(t, "Scores.json", res.Body().Raw()) 19 | } 20 | 21 | func TestScoresCtftimeList(t *testing.T) { 22 | setup(t) 23 | defer teardown(t) 24 | 25 | e := heDefault(t) 26 | e = heHost(e, "ctfzone.test") 27 | 28 | res := e.GET("/api/scores/ctftime"). 29 | Expect(). 30 | Status(200) 31 | 32 | checkJSONSchema(t, "ScoresCTFTime.json", res.Body().Raw()) 33 | } 34 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/components/sidebar/Sidebar.module.css: -------------------------------------------------------------------------------- 1 | .sider { 2 | } 3 | 4 | .logo { 5 | height: 64px; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | } 10 | 11 | .logo img { 12 | width: 60px; 13 | } 14 | 15 | .toggle { 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | background-color: var(--trigger-background); 20 | color: var(--text-color-secondary-dark); 21 | padding: 10px; 22 | font-size: 15px; 23 | cursor: pointer; 24 | } 25 | 26 | .toggle:hover { 27 | color: var(--text-color-dark); 28 | } 29 | 30 | .layout { 31 | height: 100%; 32 | background-color: var(--dark-background); 33 | } 34 | -------------------------------------------------------------------------------- /controllers/schemas/admin/users/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminUsersCreate", 3 | "description": "User create request", 4 | "type": "object", 5 | "properties": { 6 | "name": { 7 | "type": "string" 8 | }, 9 | "email": { 10 | "type": "string", 11 | "format": "email" 12 | }, 13 | "password": { 14 | "type": "string", 15 | "minLength": 8 16 | }, 17 | "isActivated": { 18 | "type": "boolean" 19 | }, 20 | "extra": { 21 | "type": "object" 22 | } 23 | }, 24 | "additionalProperties": false, 25 | "required": [ 26 | "name", 27 | "email", 28 | "password", 29 | "isActivated", 30 | "extra" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /controllers/schemas/Error.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Error", 3 | "description": "Error information", 4 | "type": "object", 5 | "properties": { 6 | "error": { 7 | "type": "string" 8 | }, 9 | "fields": { 10 | "type": "array", 11 | "items": { 12 | "type": "object", 13 | "properties": { 14 | "field": { 15 | "type": "string" 16 | }, 17 | "error": { 18 | "type": "string" 19 | } 20 | }, 21 | "required": [ 22 | "field", 23 | "error" 24 | ] 25 | } 26 | } 27 | }, 28 | "additionalProperties": false, 29 | "required": [ 30 | "error" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /web/admin/src/components/index.js: -------------------------------------------------------------------------------- 1 | import FilterDropdown from './filter-dropdown/FilterDropdown' 2 | import FilterIcon from './filter-icon/FilterIcon' 3 | // import FlagIcon from './flag-icon/FlagIcon' 4 | import Pagination from './pagination/Pagination' 5 | import withFilters from './filters/withFilters' 6 | import MarkdownEditor from './markdown-editor/MarkdownEditor' 7 | import { 8 | textFilter, 9 | radioFilter, 10 | selectFilter, 11 | dateRangeFilter, 12 | } from './filters/filters' 13 | 14 | export { 15 | FilterDropdown, 16 | FilterIcon, 17 | // FlagIcon, 18 | Pagination, 19 | withFilters, 20 | textFilter, 21 | radioFilter, 22 | selectFilter, 23 | dateRangeFilter, 24 | MarkdownEditor, 25 | } 26 | -------------------------------------------------------------------------------- /web/public/src/components/button/Button.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | 5 | class Button extends Component { 6 | static propTypes = { 7 | children: PropTypes.node, 8 | disabled: PropTypes.bool, 9 | onClick: PropTypes.func, 10 | } 11 | 12 | render() { 13 | const { disabled, ...rest } = this.props 14 | const className = classNames({ 15 | 'ctf-button': true, 16 | 'ctf-button--disabled': disabled, 17 | }) 18 | 19 | return ( 20 | 26 | ) 27 | } 28 | } 29 | 30 | export default Button 31 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/index.js: -------------------------------------------------------------------------------- 1 | import Dashboard from './dashboard/Dashboard' 2 | import UserCreateEdit from './user-create-edit/UserCreateEdit' 3 | import UsersTable from './users-table/UsersTable' 4 | import ChallengesTable from './challenges-table/ChallengesTable' 5 | import ChallengeCreateEdit from './challenge-create-edit/ChallengeCreateEdit' 6 | import FilesTree from './files-tree/FilesTree' 7 | import AnnouncementsTable from './announcements-table/AnnouncementsTable' 8 | import AnnouncementCreateEdit from './announcement-create-edit/AnnouncementCreateEdit' 9 | 10 | export { 11 | ChallengesTable, 12 | Dashboard, 13 | UserCreateEdit, 14 | UsersTable, 15 | ChallengeCreateEdit, 16 | FilesTree, 17 | AnnouncementsTable, 18 | AnnouncementCreateEdit, 19 | } 20 | -------------------------------------------------------------------------------- /controllers/schemas/Announcement.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Announcement", 3 | "description": "Announcement model", 4 | "type": "object", 5 | "properties": { 6 | "id": { 7 | "type": "integer", 8 | "minimum": 1 9 | }, 10 | "title": { 11 | "type": "string" 12 | }, 13 | "body": { 14 | "type": "string" 15 | }, 16 | "createdAt": { 17 | "type": "string", 18 | "format": "date-time" 19 | }, 20 | "updatedAt": { 21 | "type": "string", 22 | "format": "date-time" 23 | }, 24 | "challengeId": { 25 | "type": "integer", 26 | "minimum": 1 27 | } 28 | }, 29 | "additionalProperties": false, 30 | "required": [ 31 | "id", 32 | "title", 33 | "body", 34 | "createdAt", 35 | "updatedAt" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /web/admin/src/models/files.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api' 2 | 3 | export default { 4 | state: { 5 | files: [], 6 | }, 7 | reducers: { 8 | set: (state, payload) => { 9 | return { ...state, ...payload } 10 | }, 11 | }, 12 | effects: { 13 | async list() { 14 | const response = await api.get('files') 15 | const files = response.data 16 | this.set({ files }) 17 | return files 18 | }, 19 | async upload(file) { 20 | let formData = new FormData() 21 | 22 | formData.append('file', file) 23 | 24 | const response = await api.post('files', formData, { 25 | headers: { 26 | 'Content-Type': 'multipart/form-data', 27 | }, 28 | }) 29 | 30 | const fileInfo = response.data 31 | 32 | return fileInfo 33 | }, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /middlewares/schemacheck/options.go: -------------------------------------------------------------------------------- 1 | package schemacheck 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/xeipuuv/gojsonschema" 7 | ) 8 | 9 | var defaultOptions = &options{ 10 | schema: nil, 11 | errorFunc: defaultErrorFunc, 12 | } 13 | 14 | type options struct { 15 | schema *gojsonschema.Schema 16 | errorFunc func(http.ResponseWriter, *http.Request, error) 17 | } 18 | 19 | // Option defines the functional arguments for configuring the middleware. 20 | type Option func(*options) 21 | 22 | // ErrorFunc allows you to control behavior when recaptcha check failed. 23 | // The default behavior is for a HTTP 403 status code to be written to the 24 | // ResponseWriter along with the plain-text error string. 25 | func ErrorFunc(f func(http.ResponseWriter, *http.Request, error)) Option { 26 | return func(opts *options) { 27 | opts.errorFunc = f 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/public/src/scenes/components/RequireAuth.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | 6 | const RequireAuth = Wrapped => { 7 | class Wrapper extends Component { 8 | static propTypes = { 9 | isLoggedIn: PropTypes.bool, 10 | history: PropTypes.object 11 | }; 12 | 13 | componentDidMount() { 14 | const { isLoggedIn, history } = this.props; 15 | 16 | if (!isLoggedIn) { 17 | history.push('/auth/login'); 18 | } 19 | } 20 | 21 | render() { 22 | return ; 23 | } 24 | } 25 | 26 | const mapStateToProps = state => ({ 27 | isLoggedIn: state.auth 28 | }); 29 | 30 | return withRouter(connect(mapStateToProps)(Wrapper)); 31 | }; 32 | 33 | export default RequireAuth; 34 | -------------------------------------------------------------------------------- /controllers/pagination.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/ctf-zone/ctfzone/models" 10 | ) 11 | 12 | func addLinkHeader(w http.ResponseWriter, baseURL string, pi *models.PagesInfo, params url.Values) { 13 | links := make([]string, 0) 14 | 15 | if !pi.Prev.IsZero() { 16 | v := params 17 | v.Del("before") 18 | v.Del("after") 19 | v.Set("before", fmt.Sprintf("%v", pi.Prev.Before)) 20 | links = append(links, fmt.Sprintf(`<%s>; rel="prev"`, baseURL+"?"+v.Encode())) 21 | } 22 | 23 | if !pi.Next.IsZero() { 24 | v := params 25 | v.Del("before") 26 | v.Del("after") 27 | v.Set("after", fmt.Sprintf("%v", pi.Next.After)) 28 | links = append(links, fmt.Sprintf(`<%s>; rel="next"`, baseURL+"?"+v.Encode())) 29 | } 30 | 31 | if len(links) > 0 { 32 | w.Header().Set("Link", strings.Join(links, ", ")) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /controllers/schemas/admin/challenges/update.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminChallengesUpdate", 3 | "description": "Challenge update request", 4 | "type": "object", 5 | "properties": { 6 | "title": { 7 | "type": "string" 8 | }, 9 | "description": { 10 | "type": "string" 11 | }, 12 | "categories": { 13 | "type": "array", 14 | "items": { 15 | "type": "string" 16 | } 17 | }, 18 | "points": { 19 | "type": "number" 20 | }, 21 | "difficulty": { 22 | "type": "string", 23 | "enum": ["easy", "medium", "hard"] 24 | }, 25 | "flag": { 26 | "type": "string" 27 | }, 28 | "isLocked": { 29 | "type": "boolean" 30 | } 31 | }, 32 | "additionalProperties": false, 33 | "required": [ 34 | "title", 35 | "categories", 36 | "points", 37 | "difficulty", 38 | "isLocked" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /web/public/src/scenes/scenes/auth/Logout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | 6 | class Logout extends Component { 7 | static propTypes = { 8 | authLogout: PropTypes.func, 9 | history: PropTypes.object 10 | }; 11 | 12 | componentDidMount() { 13 | const { authLogout, history } = this.props; 14 | 15 | authLogout(); 16 | history.push('/auth/login'); 17 | } 18 | 19 | render() { 20 | return
; 21 | } 22 | } 23 | 24 | const mapStateToProps = state => ({ 25 | ...state.api.effects.auth.logout 26 | }); 27 | 28 | const mapDispatchToProps = dispatch => ({ 29 | authLogout: dispatch.auth.logout 30 | }); 31 | 32 | export default withRouter( 33 | connect( 34 | mapStateToProps, 35 | mapDispatchToProps 36 | )(Logout) 37 | ); 38 | -------------------------------------------------------------------------------- /controllers/schemas/admin/challenges/create.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "AdminChallengesCreate", 3 | "description": "Challenge create request", 4 | "type": "object", 5 | "properties": { 6 | "title": { 7 | "type": "string" 8 | }, 9 | "description": { 10 | "type": "string" 11 | }, 12 | "categories": { 13 | "type": "array", 14 | "items": { 15 | "type": "string" 16 | } 17 | }, 18 | "points": { 19 | "type": "number" 20 | }, 21 | "difficulty": { 22 | "type": "string", 23 | "enum": ["easy", "medium", "hard"] 24 | }, 25 | "flag": { 26 | "type": "string" 27 | }, 28 | "isLocked": { 29 | "type": "boolean" 30 | } 31 | }, 32 | "additionalProperties": false, 33 | "required": [ 34 | "title", 35 | "categories", 36 | "points", 37 | "difficulty", 38 | "flag", 39 | "isLocked" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /web/admin/src/components/filter-dropdown/FilterDropdown.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import styles from './FilterDropdown.module.css' 5 | 6 | class FilterDropdown extends Component { 7 | 8 | static propTypes = { 9 | onConfirm: PropTypes.func, 10 | onReset: PropTypes.func, 11 | children: PropTypes.node, 12 | } 13 | 14 | render() { 15 | return ( 16 |
17 |
18 | {this.props.children} 19 |
20 |
21 | OK 22 | Reset 23 |
24 |
25 | ) 26 | } 27 | } 28 | 29 | export default FilterDropdown 30 | -------------------------------------------------------------------------------- /web/admin/src/index.css: -------------------------------------------------------------------------------- 1 | @import "~antd/dist/antd.css"; 2 | @import "~codemirror/lib/codemirror.css"; 3 | @import "~codemirror/theme/idea.css"; 4 | 5 | :root { 6 | /* TEXT COLORS */ 7 | --heading-color: color-mod(#000 alpha(85%)); 8 | --text-color: color-mod(#000 alpha(65%)); 9 | --text-color-secondary: color-mod(#000 alpha(45%)); 10 | 11 | --heading-color-dark: color-mod(#fff alpha(100%)); 12 | --text-color-dark: color-mod(#fff alpha(85%)); 13 | --text-color-secondary-dark: color-mod(#fff alpha(65%)); 14 | 15 | /* BACKGROUND COLORS */ 16 | --dark-background: #001529; 17 | --light-background: #f8f8f8; 18 | --trigger-background: #002140; 19 | 20 | /* FILTER ICON */ 21 | --filter-color-active: #108ee9; 22 | --filter-color-inactive: #aaa; 23 | 24 | /* FORM */ 25 | --field-border-color: #d9d9d9; 26 | } 27 | 28 | html, 29 | body, 30 | #root { 31 | width: 100%; 32 | min-height: 100vh; 33 | } 34 | -------------------------------------------------------------------------------- /controllers/schemas/User.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "User", 3 | "description": "User model", 4 | "type": "object", 5 | "properties": { 6 | "id": { 7 | "type": "integer", 8 | "minimum": 1 9 | }, 10 | "name": { 11 | "type": "string" 12 | }, 13 | "email": { 14 | "type": "string", 15 | "format": "email" 16 | }, 17 | "password": { 18 | "type": "string", 19 | "minLength": 8 20 | }, 21 | "isActivated": { 22 | "type": "boolean" 23 | }, 24 | "extra": { 25 | "type": "object" 26 | }, 27 | "createdAt": { 28 | "type": "string", 29 | "format": "date-time" 30 | }, 31 | "updatedAt": { 32 | "type": "string", 33 | "format": "date-time" 34 | } 35 | }, 36 | "additionalProperties": false, 37 | "required": [ 38 | "id", 39 | "name", 40 | "email", 41 | "extra", 42 | "isActivated" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /models/fixtures/tokens.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | user_id: 4 3 | token: 1534e7c3fc6b3dbb411a5170b5fa94cd95f324f1ca072853ab2cb34c1378c061 4 | type: activate 5 | expires_at: RAW=NOW() AT TIME ZONE 'UTC' 6 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 7 | 8 | - id: 2 9 | user_id: 4 10 | token: 5b2fae511a73355aff9a99b2164caa343889773150854d6160e4acc6c0137f17 11 | type: activate 12 | expires_at: RAW=NOW() AT TIME ZONE 'UTC' + INTERVAL '1 hour' 13 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 14 | 15 | - id: 3 16 | user_id: 3 17 | token: 150ef2dc5ab146ef23bc1a239fcb7f631006677453782dc779dc8a45a763751f 18 | type: reset 19 | expires_at: RAW=NOW() AT TIME ZONE 'UTC' 20 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 21 | 22 | - id: 4 23 | user_id: 3 24 | token: e2e5fbe043d0f05f37405f363a1f442124ea5de85f862e2138fcb5b9d6eff142 25 | type: reset 26 | expires_at: RAW=NOW() AT TIME ZONE 'UTC' + INTERVAL '1 hour' 27 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 28 | -------------------------------------------------------------------------------- /web/admin/src/index.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | 6 | import { init } from '@rematch/core' 7 | import apiPlugin from './utils/rematch-api' 8 | import { Provider } from 'react-redux' 9 | import { createLogger } from 'redux-logger' 10 | 11 | import { BrowserRouter as Router } from 'react-router-dom'; 12 | 13 | import models from './models' 14 | import App from './scenes/App' 15 | 16 | import './index.css' 17 | 18 | let middlewares = [] 19 | 20 | if (process.env.NODE_ENV === 'development') { 21 | middlewares.push(createLogger()) 22 | } 23 | 24 | const store = init({ 25 | models, 26 | redux: { 27 | middlewares: middlewares, 28 | }, 29 | plugins: [ 30 | apiPlugin({}), 31 | ], 32 | }) 33 | 34 | ReactDOM.render( 35 | 36 | 37 | 38 | 39 | , 40 | document.getElementById('root'), 41 | ) 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | services: 11 | postgres: 12 | image: postgres:11.5 13 | env: 14 | POSTGRES_USER: postgres 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: postgres_test 17 | ports: 18 | - 5432/tcp 19 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 20 | steps: 21 | - name: Set up Golang 22 | uses: actions/setup-go@v1 23 | with: 24 | go-version: 1.12 25 | 26 | - name: Check out code 27 | uses: actions/checkout@v1 28 | 29 | - name: Run unit tests 30 | run: make test 31 | env: 32 | CTF_DB_DSN: 'postgres://postgres:postgres@localhost:${{ job.services.postgres.ports[5432] }}/postgres_test?sslmode=disable' 33 | -------------------------------------------------------------------------------- /controllers/schemas/ScoresCTFTime.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "ScoresCTFTime", 3 | "description": "List of scores in CTFTime format (https://ctftime.org/json-scoreboard-feed)", 4 | "type": "object", 5 | "properties": { 6 | "standings": { 7 | "type": "array", 8 | "items": { 9 | "type": "object", 10 | "properties": { 11 | "team": { 12 | "type": "string" 13 | }, 14 | "score": { 15 | "type": "number" 16 | }, 17 | "pos": { 18 | "type": "number" 19 | }, 20 | "lastAccept": { 21 | "type": "number" 22 | } 23 | }, 24 | "additionalProperties": false, 25 | "required": [ 26 | "pos", 27 | "score", 28 | "team", 29 | "lastAccept" 30 | ] 31 | } 32 | } 33 | }, 34 | "additionalProperties": false, 35 | "required": [ 36 | "standings" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /web/admin/src/components/with-fields/withFields.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | export default (WrappedComponent) => { 4 | return class WithFiedls extends Component { 5 | 6 | static defaultState = { 7 | fields: {}, 8 | updated: {}, 9 | } 10 | 11 | state = { 12 | fields: {}, 13 | updated: {}, 14 | } 15 | 16 | handleFieldsChange = (changedFields) => { 17 | this.setState(({ fields, updated }) => ({ 18 | fields: { ...fields, ...changedFields }, 19 | updated: Object.assign( 20 | updated, 21 | ...Object.keys(changedFields).map((field) => ({ [field]: new Date() })), 22 | ), 23 | })) 24 | } 25 | 26 | render() { 27 | const props = { 28 | ...this.props, 29 | fields: this.state.fields, 30 | handleFieldsChange: this.handleFieldsChange, 31 | } 32 | 33 | return 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /models/fixtures/solutions.yml: -------------------------------------------------------------------------------- 1 | - user_id: 1 # LC↯BC 2 | challenge_id: 1 # web-100 3 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 4 | 5 | - user_id: 1 # LC↯BC 6 | challenge_id: 3 # crypto-300 7 | created_at: RAW=NOW() AT TIME ZONE 'UTC' + INTERVAL '1 second' 8 | 9 | - user_id: 2 # PPP 10 | challenge_id: 1 # web-100 11 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 12 | 13 | - user_id: 2 # PPP 14 | challenge_id: 3 # crypto-300 15 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 16 | 17 | - user_id: 3 # Dragon Sector 18 | challenge_id: 1 # web-100 19 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 20 | 21 | - user_id: 3 # Dragon Sector 22 | challenge_id: 2 # reverse-200 23 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 24 | 25 | - user_id: 5 # TokyoWesterns 26 | challenge_id: 1 # web-100 27 | created_at: RAW=NOW() AT TIME ZONE 'UTC' + INTERVAL '1 second' 28 | 29 | - user_id: 5 # TokyoWesterns 30 | challenge_id: 3 # crypto-300 31 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 32 | -------------------------------------------------------------------------------- /middlewares/recaptcha/handler.go: -------------------------------------------------------------------------------- 1 | package recaptcha 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type handler struct { 8 | next http.Handler 9 | opts *options 10 | } 11 | 12 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 13 | err := check( 14 | h.opts.secret, 15 | r.Header.Get(h.opts.header), 16 | r.Header.Get("X-Forwared-For")) 17 | 18 | if err != nil { 19 | h.opts.errorFunc(w, r, err) 20 | return 21 | } 22 | 23 | h.next.ServeHTTP(w, r) 24 | } 25 | 26 | func defaultErrorFunc(w http.ResponseWriter, r *http.Request, err error) { 27 | http.Error(w, "Wrong captcha", 403) 28 | } 29 | 30 | func New(secret string, opts ...Option) func(http.Handler) http.Handler { 31 | return func(next http.Handler) http.Handler { 32 | do := *defaultOptions 33 | 34 | do.secret = secret 35 | 36 | h := &handler{ 37 | next: next, 38 | opts: &do, 39 | } 40 | 41 | for _, fn := range opts { 42 | fn(h.opts) 43 | } 44 | 45 | return h 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/public/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctfzone-public", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@rematch/core": "^1.4.0", 7 | "axios": "^0.21.1", 8 | "classnames": "^2.2.6", 9 | "dayjs": "^1.10.3", 10 | "http-proxy-middleware": "^1.0.6", 11 | "node-sass": "^4.0.0", 12 | "npm-check-updates": "^10.2.5", 13 | "rc-form": "^2.4.12", 14 | "react": "^17.0.1", 15 | "react-dom": "^17.0.1", 16 | "react-markdown": "^5.0.3", 17 | "react-recaptcha": "^2.3.10", 18 | "react-redux": "^7.2.2", 19 | "react-router-dom": "^5.2.0", 20 | "react-scripts": "4.0.1" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "proxy": "http://localhost:8000/", 32 | "browserslist": [">0.2%", "not dead", "not ie <= 11", "not op_mini all"] 33 | } 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest AS ui 2 | WORKDIR /usr/src/ 3 | COPY web . 4 | ENV NODE_ENV production 5 | 6 | WORKDIR /usr/src/public 7 | RUN npm --production=false install 8 | RUN npm run build 9 | 10 | WORKDIR /usr/src/admin 11 | RUN npm --production=false install 12 | RUN npm run build 13 | 14 | 15 | FROM golang:latest AS api 16 | WORKDIR /go/src/github.com/ctf-zone/ctfzone 17 | COPY . . 18 | ENV GO111MODULE on 19 | ENV GOOS linux 20 | ENV CGO_ENABLED 0 21 | RUN make 22 | 23 | 24 | FROM alpine:latest 25 | RUN addgroup -S ctfzone && adduser -S -u 1001 -G ctfzone ctfzone 26 | RUN apk add --no-cache ca-certificates 27 | USER ctfzone 28 | WORKDIR /home/ctfzone 29 | RUN mkdir -p files static/public static/admin 30 | COPY templates ./templates 31 | COPY --from=ui --chown=ctfzone:ctfzone /usr/src/public/build static/public/ 32 | COPY --from=ui --chown=ctfzone:ctfzone /usr/src/admin/build static/admin/ 33 | COPY --from=api --chown=ctfzone:ctfzone /go/src/github.com/ctf-zone/ctfzone/ctfzone . 34 | EXPOSE 8080 8443 35 | CMD ["./ctfzone"] 36 | -------------------------------------------------------------------------------- /models/solutions.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Solution struct { 8 | UserID int64 `db:"user_id" json:"userId"` 9 | ChallengeID int64 `db:"challenge_id" json:"challengeId"` 10 | CreatedAt time.Time `db:"created_at" json:"createdAt"` 11 | } 12 | 13 | func (r *Repository) SolutionsInsert(o *Solution) error { 14 | o.CreatedAt = now() 15 | 16 | stmt, err := r.db.PrepareNamed( 17 | "INSERT INTO solutions (user_id, challenge_id, created_at) " + 18 | "VALUES(:user_id, :challenge_id, :created_at) " + 19 | "RETURNING created_at") 20 | 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return stmt.QueryRowx(o).Scan(&o.CreatedAt) 26 | } 27 | 28 | func (r *Repository) SolutionsOneByID(userID, challengeID int64) (*Solution, error) { 29 | var o Solution 30 | 31 | err := r.db.Get(&o, "SELECT * FROM solutions WHERE user_id = $1 and challenge_id = $2", userID, challengeID) 32 | 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return &o, nil 38 | } 39 | -------------------------------------------------------------------------------- /controllers/file.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "io/ioutil" 5 | "path" 6 | "time" 7 | ) 8 | 9 | type File struct { 10 | Name string `json:"name"` 11 | IsDirectory bool `json:"isDirectory"` 12 | Items []File `json:"items"` 13 | Size int64 `json:"size"` 14 | UpdatedAt time.Time `json:"updatedAt"` 15 | } 16 | 17 | func getDirFiles(dirPath string) ([]File, error) { 18 | var err error 19 | 20 | files := make([]File, 0) 21 | 22 | ff, err := ioutil.ReadDir(dirPath) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | for _, f := range ff { 28 | var items []File 29 | 30 | if f.IsDir() { 31 | items, err = getDirFiles(path.Join(dirPath, f.Name())) 32 | if err != nil { 33 | return nil, err 34 | } 35 | } 36 | 37 | files = append(files, File{ 38 | Name: f.Name(), 39 | IsDirectory: f.IsDir(), 40 | Items: items, 41 | Size: f.Size(), 42 | UpdatedAt: f.ModTime(), 43 | }) 44 | } 45 | 46 | return files, nil 47 | } 48 | -------------------------------------------------------------------------------- /web/admin/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 8, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "modules": true 8 | } 9 | }, 10 | "env": { 11 | "browser": true, 12 | "node": true 13 | }, 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:react/recommended" 17 | ], 18 | "rules": { 19 | "arrow-parens": ["error", "always"], 20 | "react/sort-comp": ["error", { 21 | "order": [ 22 | "static-methods", 23 | "lifecycle", 24 | "everything-else", 25 | "/^handle.+$/", 26 | "/^render.+$/", 27 | "render" 28 | ] 29 | }], 30 | "object-curly-spacing": ["error", "always"], 31 | "react/jsx-curly-spacing": ["error", "never"], 32 | "template-curly-spacing": ["error", "never"], 33 | "lines-between-class-members": ["error", "always"], 34 | "space-before-function-paren": ["error", "never"], 35 | "comma-dangle": ["error", "always-multiline"] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/public/src/models/challenges.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api'; 2 | 3 | export default { 4 | state: { 5 | // TODO: use array 6 | items: {} 7 | }, 8 | reducers: { 9 | set: (state, payload) => { 10 | return { ...state, ...payload }; 11 | }, 12 | setItem: (state, { id, item }) => { 13 | return { 14 | ...state, 15 | items: { 16 | ...state.items, 17 | [id]: item 18 | } 19 | }; 20 | } 21 | }, 22 | effects: { 23 | async list() { 24 | const response = await api.get('/challenges'); 25 | const items = response.data.reduce((result, item) => { 26 | result[item.challenge.id] = item; 27 | return result; 28 | }, {}); 29 | 30 | this.set({ items }); 31 | 32 | return items; 33 | }, 34 | async get(id) { 35 | const response = await api.get(`/challenges/${id}`); 36 | const item = response.data; 37 | 38 | this.setItem({ id: item.challenge.id, item }); 39 | 40 | return item; 41 | } 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /controllers/utils.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | func jsonDecode(r *http.Request, out interface{}) error { 10 | body, err := ioutil.ReadAll(r.Body) 11 | if err != nil { 12 | return err 13 | } 14 | return json.Unmarshal(body, out) 15 | } 16 | 17 | func requestHost(r *http.Request) (host string) { 18 | // not standard, but most popular 19 | host = r.Header.Get("X-Forwarded-Host") 20 | if host != "" { 21 | return 22 | } 23 | 24 | // if all else fails fall back to request host 25 | host = r.Host 26 | return 27 | } 28 | 29 | func responseJSON(w http.ResponseWriter, data interface{}) error { 30 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 31 | w.WriteHeader(200) 32 | return json.NewEncoder(w).Encode(data) 33 | } 34 | 35 | func responseOK(w http.ResponseWriter) { 36 | w.Header().Set("Content-Type", "") 37 | w.WriteHeader(200) 38 | } 39 | 40 | func responseCreated(w http.ResponseWriter) { 41 | w.Header().Set("Content-Type", "") 42 | w.WriteHeader(201) 43 | } 44 | -------------------------------------------------------------------------------- /web/public/src/components/form-item/FormItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | 5 | class FormItem extends Component { 6 | 7 | static propTypes = { 8 | label: PropTypes.string, 9 | children: PropTypes.node, 10 | errors: PropTypes.array, 11 | } 12 | 13 | render() { 14 | const { children, errors, label } = this.props 15 | const valid = !errors || errors.length === 0 16 | const baseClass = 'ctf-form-item' 17 | const className = classNames({ 18 | [baseClass]: true, 19 | [baseClass + '--valid']: valid, 20 | [baseClass + '--invalid']: !valid, 21 | }) 22 | 23 | return ( 24 |
25 |
26 | {children} 27 | 28 |
29 |
30 | {(errors || []).join(', ')} 31 |
32 |
33 | ) 34 | } 35 | } 36 | 37 | export default FormItem 38 | -------------------------------------------------------------------------------- /web/admin/src/utils/form.js: -------------------------------------------------------------------------------- 1 | import { Form } from 'antd' 2 | 3 | export const hasErrors = (fieldsError) => { 4 | return Object 5 | .keys(fieldsError) 6 | .some((field) => fieldsError[field]); 7 | } 8 | 9 | export const addErrors = (fields, errors) => { 10 | return Object.assign( 11 | fields, 12 | ...Object.keys(errors).map((field) => ({ 13 | [field]: { 14 | ...fields[field], 15 | errors: errors[field].map((e) => new Error(e)), 16 | }, 17 | })), 18 | ) 19 | } 20 | 21 | export const addValues = (fields, values) => { 22 | return Object.assign( 23 | fields, 24 | ...Object.keys(values).map((field) => ({ 25 | [field]: { 26 | ...fields[field], 27 | value: values[field], 28 | }, 29 | })), 30 | ) 31 | } 32 | 33 | export const mapPropsToFields = ({ fields }) => { 34 | return Object.assign( 35 | {}, 36 | ...Object.keys(fields || {}).map((field) => { 37 | return { 38 | [field]: Form.createFormField({ 39 | ...fields[field], 40 | }), 41 | } 42 | }), 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /controllers/game.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/ctf-zone/ctfzone/config" 8 | ) 9 | 10 | // GameInfo return information about CTF 11 | // summary: Returns information about game 12 | // tags: [game] 13 | func GameInfo(c *config.Game) http.HandlerFunc { 14 | 15 | type Response struct { 16 | Status string `json:"status"` 17 | Start time.Time `json:"start"` 18 | End time.Time `json:"end"` 19 | } 20 | 21 | start, end := c.StartTime(), c.EndTime() 22 | 23 | return func(w http.ResponseWriter, r *http.Request) { 24 | 25 | info := &Response{ 26 | Start: start.UTC(), 27 | End: end.UTC(), 28 | } 29 | 30 | now := time.Now() 31 | 32 | if now.Before(start) { 33 | info.Status = "countdown" 34 | } else if now.After(start) && now.Before(end) { 35 | info.Status = "started" 36 | } else if now.After(end) { 37 | info.Status = "ended" 38 | } 39 | 40 | // schema: GameInfo 41 | if err := responseJSON(w, info); err != nil { 42 | handleError(w, r, ErrInternal.SetError(err)) 43 | return 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /middlewares/recaptcha/options.go: -------------------------------------------------------------------------------- 1 | package recaptcha 2 | 3 | import "net/http" 4 | 5 | var defaultOptions = &options{ 6 | header: "X-G-Recaptcha-Response", 7 | secret: "", 8 | errorFunc: defaultErrorFunc, 9 | } 10 | 11 | type options struct { 12 | header string 13 | secret string 14 | errorFunc func(http.ResponseWriter, *http.Request, error) 15 | } 16 | 17 | // Option defines the functional arguments for configuring the middleware. 18 | type Option func(*options) 19 | 20 | // Header sets the header name from which the recaptcha response will be taken. 21 | // Default value is "X-G-Recaptcha-Response". 22 | func Header(s string) Option { 23 | return func(opts *options) { 24 | opts.header = s 25 | } 26 | } 27 | 28 | // ErrorFunc allows you to control behavior when recaptcha check failed. 29 | // The default behavior is for a HTTP 403 status code to be written to the 30 | // ResponseWriter along with the plain-text error string. 31 | func ErrorFunc(f func(http.ResponseWriter, *http.Request, error)) Option { 32 | return func(opts *options) { 33 | opts.errorFunc = f 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /controllers/schemas/Challenge.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Challenge", 3 | "description": "Challenge model", 4 | "type": "object", 5 | "properties": { 6 | "id": { 7 | "type": "number" 8 | }, 9 | "title": { 10 | "type": "string" 11 | }, 12 | "description": { 13 | "type": "string" 14 | }, 15 | "difficulty": { 16 | "type": "string", 17 | "enum": ["easy", "medium", "hard"] 18 | }, 19 | "categories": { 20 | "type": "array", 21 | "items": { 22 | "type": "string" 23 | } 24 | }, 25 | "points": { 26 | "type": "number" 27 | }, 28 | "isLocked": { 29 | "type": "boolean" 30 | }, 31 | "createdAt": { 32 | "type": "string", 33 | "format": "date-time" 34 | }, 35 | "updatedAt": { 36 | "type": "string", 37 | "format": "date-time" 38 | } 39 | }, 40 | "additionalProperties": false, 41 | "required": [ 42 | "id", 43 | "title", 44 | "description", 45 | "difficulty", 46 | "categories", 47 | "points", 48 | "createdAt", 49 | "updatedAt" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /web/admin/src/components/pagination/Pagination.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Button, Icon } from 'antd' 4 | 5 | import styles from './Pagination.module.css' 6 | 7 | class Pagination extends Component { 8 | 9 | static propTypes = { 10 | onNext: PropTypes.func, 11 | onPrev: PropTypes.func, 12 | hasNext: PropTypes.bool, 13 | hasPrev: PropTypes.bool, 14 | } 15 | 16 | render() { 17 | return ( 18 |
19 | 20 | 28 | 36 | 37 |
38 | ) 39 | } 40 | } 41 | 42 | export default Pagination 43 | -------------------------------------------------------------------------------- /models/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/golang-migrate/migrate/v4" 5 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 6 | bindata "github.com/golang-migrate/migrate/v4/source/go_bindata" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func Up(dsn string) error { 11 | assets := bindata.Resource(AssetNames(), 12 | func(name string) ([]byte, error) { 13 | return Asset(name) 14 | }) 15 | 16 | src, err := bindata.WithInstance(assets) 17 | if err != nil { 18 | return errors.Wrap(err, "model: fail to create bindata source") 19 | } 20 | 21 | mgr, err := migrate.NewWithSourceInstance("go-bindata", src, dsn) 22 | if err != nil { 23 | return errors.Wrap(err, "model: fail init migrate") 24 | } 25 | 26 | if err := mgr.Up(); err != nil && err != migrate.ErrNoChange { 27 | return errors.Wrap(err, "model: migrations up failed") 28 | } 29 | 30 | if err := src.Close(); err != nil { 31 | return errors.Wrap(err, "model: fail to close source") 32 | } 33 | 34 | if se, de := mgr.Close(); se != nil || de != nil { 35 | return errors.Wrap(de, "model: fail to close source") 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /web/public/src/models/news.js: -------------------------------------------------------------------------------- 1 | import { stringify } from 'qs'; 2 | 3 | import api from '../utils/api'; 4 | 5 | export default { 6 | state: { 7 | items: [], 8 | hints: {} 9 | }, 10 | reducers: { 11 | set: (state, payload) => { 12 | return { ...state, ...payload }; 13 | }, 14 | setHints(state, { challengeId, hints }) { 15 | return { 16 | ...state, 17 | hints: { 18 | ...state.hints, 19 | [challengeId]: hints 20 | } 21 | }; 22 | } 23 | }, 24 | effects: { 25 | async list({ link = null, filters = {} }) { 26 | const query = stringify(filters); 27 | const response = await api.get( 28 | link || query ? `/annonncements?${query}` : '/announcements' 29 | ); 30 | const items = response.data; 31 | 32 | this.set({ items }); 33 | 34 | return items; 35 | }, 36 | async getHints(challengeId) { 37 | const response = await api.get( 38 | `/announcements?challengeId=${challengeId}` 39 | ); 40 | const hints = response.data; 41 | this.setHints({ challengeId, hints }); 42 | return hints; 43 | } 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /web/admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctfzone-admin", 3 | "version": "1.0.0", 4 | "description": "CTFZone Admin UI", 5 | "author": "russtone@yandex.ru", 6 | "license": "MIT", 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "@rematch/core": "^1.4.0", 10 | "antd": "^3.26.20", 11 | "axios": "^0.21.1", 12 | "codemirror": "^5.59.1", 13 | "parse-link-header": "^1.0.1", 14 | "rc-form": "^2.4.12", 15 | "react": "^17.0.1", 16 | "react-codemirror2": "^7.2.1", 17 | "react-dom": "^17.0.1", 18 | "react-markdown": "^5.0.3", 19 | "react-moment": "^1.1.1", 20 | "react-redux": "^7.2.2", 21 | "react-router-dom": "^5.2.0", 22 | "react-scripts": "4.0.1" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "proxy": "http://localhost:8000/", 34 | "browserslist": [ 35 | ">0.2%", 36 | "not dead", 37 | "not ie <= 11", 38 | "not op_mini all" 39 | ], 40 | "devDependencies": { 41 | "redux-logger": "^3.0.6" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /web/public/src/scenes/components/Nav.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'react-router-dom' 4 | 5 | class Nav extends Component { 6 | 7 | static propTypes = { 8 | leftItems: PropTypes.array, 9 | rightItems: PropTypes.array, 10 | rightPrefix: PropTypes.node, 11 | } 12 | 13 | static defaultProps = { 14 | leftItems: [], 15 | rightItems: [], 16 | } 17 | 18 | renderLinks(items) { 19 | return items.filter(({ hidden }) => !hidden).map(({ title, path }, i) => { 20 | return ( 21 |
22 | {title} 23 |
24 | ) 25 | }) 26 | } 27 | 28 | render() { 29 | const { leftItems, rightItems, rightPrefix } = this.props 30 | 31 | return ( 32 |
33 |
34 | {this.renderLinks(leftItems)} 35 |
36 |
37 | {rightPrefix} 38 | {this.renderLinks(rightItems)} 39 |
40 |
41 | ) 42 | } 43 | } 44 | 45 | export default Nav 46 | -------------------------------------------------------------------------------- /models/likes.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Like struct { 8 | UserID int64 `db:"user_id" json:"userId"` 9 | ChallengeID int64 `db:"challenge_id" json:"challengeId"` 10 | CreatedAt time.Time `db:"created_at" json:"createdAt"` 11 | } 12 | 13 | func (r *Repository) LikesInsert(o *Like) error { 14 | o.CreatedAt = now() 15 | 16 | stmt, err := r.db.PrepareNamed( 17 | "INSERT INTO likes (user_id, challenge_id, created_at) " + 18 | "VALUES(:user_id, :challenge_id, :created_at) " + 19 | "RETURNING created_at") 20 | 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return stmt.QueryRowx(o).Scan(&o.CreatedAt) 26 | } 27 | 28 | func (r *Repository) LikesDelete(userID, challengeID int64) error { 29 | return r.db.QueryRow("DELETE FROM likes WHERE user_id = $1 AND challenge_id = $2 RETURNING user_id", 30 | userID, challengeID).Scan(&userID) 31 | } 32 | 33 | func (r *Repository) LikesOneByID(userID, challengeID int64) (*Like, error) { 34 | var o Like 35 | 36 | err := r.db.Get(&o, "SELECT * FROM likes WHERE user_id = $1 and challenge_id = $2", userID, challengeID) 37 | 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return &o, nil 43 | } 44 | -------------------------------------------------------------------------------- /models/solutions_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | . "github.com/ctf-zone/ctfzone/models" 11 | ) 12 | 13 | func Test_Solutions_Insert_Success(t *testing.T) { 14 | setup(t) 15 | defer teardown(t) 16 | 17 | o := &Solution{ 18 | UserID: 5, 19 | ChallengeID: 2, 20 | } 21 | 22 | err := db.SolutionsInsert(o) 23 | assert.NoError(t, err) 24 | assert.WithinDuration(t, time.Now().UTC(), o.CreatedAt, 5*time.Second) 25 | } 26 | 27 | func Test_Solutions_Insert_Duplicate(t *testing.T) { 28 | setup(t) 29 | defer teardown(t) 30 | 31 | o := &Solution{ 32 | UserID: 1, 33 | ChallengeID: 1, 34 | } 35 | 36 | err := db.SolutionsInsert(o) 37 | assert.Error(t, err) 38 | } 39 | 40 | func Test_Solutions_OneByID_Success(t *testing.T) { 41 | setup(t) 42 | defer teardown(t) 43 | 44 | o, err := db.SolutionsOneByID(1, 1) 45 | assert.NoError(t, err) 46 | assert.NotNil(t, o) 47 | } 48 | 49 | func Test_Solutions_OneByID_NotExist(t *testing.T) { 50 | setup(t) 51 | defer teardown(t) 52 | 53 | o, err := db.SolutionsOneByID(1, 1337) 54 | assert.Error(t, err) 55 | assert.Nil(t, o) 56 | assert.EqualError(t, err, sql.ErrNoRows.Error()) 57 | } 58 | -------------------------------------------------------------------------------- /middlewares/cntcheck/handler.go: -------------------------------------------------------------------------------- 1 | package cntcheck 2 | 3 | import ( 4 | "errors" 5 | "mime" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type handler struct { 11 | next http.Handler 12 | opts *options 13 | } 14 | 15 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 16 | mediatype, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) 17 | charset, ok := params["charset"] 18 | if !ok { 19 | charset = "UTF-8" 20 | } 21 | 22 | // per net/http doc, means that the length is known and non-null 23 | if r.ContentLength > 0 && 24 | !(mediatype == "application/json" && strings.ToUpper(charset) == "UTF-8") { 25 | h.opts.errorFunc(w, r, errors.New("bad Content-Type or charset")) 26 | return 27 | } 28 | 29 | h.next.ServeHTTP(w, r) 30 | } 31 | 32 | func defaultErrorFunc(w http.ResponseWriter, r *http.Request, err error) { 33 | http.Error(w, "Bad Content-Type or charset, expected 'application/json'", 415) 34 | } 35 | 36 | func New(opts ...Option) func(http.Handler) http.Handler { 37 | return func(next http.Handler) http.Handler { 38 | do := *defaultOptions 39 | 40 | h := &handler{ 41 | next: next, 42 | opts: &do, 43 | } 44 | 45 | for _, f := range opts { 46 | f(h.opts) 47 | } 48 | 49 | return h 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /web/public/src/models/auth.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api'; 2 | 3 | export default { 4 | state: null, 5 | reducers: { 6 | set: (state, payload) => { 7 | return payload; 8 | } 9 | }, 10 | // TODO: replace data with fields 11 | effects: { 12 | async register(data) { 13 | const { reCaptchaResponse, ...rest } = data; 14 | console.log(reCaptchaResponse, rest); 15 | await api.post('/auth/register', rest, { 16 | headers: { 17 | 'X-G-Recaptcha-Response': reCaptchaResponse, 18 | } 19 | }); 20 | }, 21 | async login(data) { 22 | await api.post('/auth/login', data); 23 | this.set(true); 24 | }, 25 | async logout() { 26 | await api.post('/auth/logout', {}); 27 | this.set(false); 28 | }, 29 | async check() { 30 | const response = await api.get('/auth/check'); 31 | const { isLoggedIn } = response.data; 32 | this.set(isLoggedIn); 33 | }, 34 | async resetPassword(data) { 35 | await api.post('/auth/reset-password', data); 36 | }, 37 | async sendToken({ type, token, email }) { 38 | await api.post('/auth/send-token', { type, token, email }); 39 | }, 40 | async activate({ token }) { 41 | await api.post('/auth/activate', { token }); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /middlewares/timecheck/handler.go: -------------------------------------------------------------------------------- 1 | package timecheck 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | var ( 10 | MinTime = time.Time{} 11 | MaxTime = time.Unix(1<<63-62135596801, 999999999) 12 | ) 13 | 14 | var ( 15 | ErrTooEarly = errors.New("timecheck: too early") 16 | ErrTooLate = errors.New("timecheck: too late") 17 | ) 18 | 19 | type handler struct { 20 | next http.Handler 21 | opts *options 22 | } 23 | 24 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 25 | 26 | now := time.Now().UTC() 27 | 28 | if now.Before(h.opts.start) { 29 | h.opts.errorFunc(w, r, ErrTooEarly) 30 | return 31 | } 32 | 33 | if now.After(h.opts.end) { 34 | h.opts.errorFunc(w, r, ErrTooLate) 35 | return 36 | } 37 | 38 | h.next.ServeHTTP(w, r) 39 | } 40 | 41 | func defaultErrorFunc(w http.ResponseWriter, r *http.Request, err error) { 42 | http.Error(w, "Access is limited for a period of time", 403) 43 | } 44 | 45 | func New(start, end time.Time, opts ...Option) func(http.Handler) http.Handler { 46 | return func(next http.Handler) http.Handler { 47 | do := *defaultOptions 48 | 49 | do.start = start 50 | do.end = end 51 | 52 | h := &handler{ 53 | next: next, 54 | opts: &do, 55 | } 56 | 57 | for _, f := range opts { 58 | f(h.opts) 59 | } 60 | 61 | return h 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /web/admin/src/components/markdown-editor/MarkdownEditor.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { Tabs } from 'antd' 5 | import { Controlled as CodeMirror } from 'react-codemirror2' 6 | import ReactMarkdown from 'react-markdown' 7 | 8 | import 'codemirror/mode/gfm/gfm' 9 | 10 | import './MarkdownEditor.css' 11 | import styles from './MarkdownEditor.module.css' 12 | 13 | class MarkdownEditor extends Component { 14 | 15 | static propTypes = { 16 | value: PropTypes.string, 17 | } 18 | 19 | render() { 20 | const { value, ...rest } = this.props 21 | 22 | return ( 23 | 24 | 25 |
26 | 34 |
35 |
36 | 37 |
38 | 39 |
40 |
41 |
42 | ) 43 | } 44 | } 45 | 46 | export default MarkdownEditor 47 | -------------------------------------------------------------------------------- /models/fixtures/challenges.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | title: Web 3 | categories: '{web}' 4 | points: 100 5 | description: Web challenge 6 | difficulty: easy 7 | flag_hash: 845151a1e2230f5cb2bf3f96354d4b82a4bd30ee1b132469b2a4884ae50fe661 8 | is_locked: false 9 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 10 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 11 | 12 | - id: 2 13 | title: Reverse 14 | categories: '{reverse}' 15 | points: 200 16 | description: Reverse challenge 17 | difficulty: medium 18 | flag_hash: 8633dd1332ede139ffa48af8ac62edf8ff76208dedbcf7a9abbbb3dc02bfc8f9 19 | is_locked: false 20 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 21 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 22 | 23 | - id: 3 24 | title: Crypto 25 | categories: '{crypto}' 26 | points: 300 27 | description: Crypto challenge 28 | difficulty: medium 29 | flag_hash: 39d5e8462b14a3694be559c51c4c150e5f201a6f7fc3b868acf01016271e3a07 30 | is_locked: false 31 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 32 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 33 | 34 | - id: 4 35 | title: PWN 36 | categories: '{pwn}' 37 | points: 500 38 | description: Pwn challenge 39 | difficulty: hard 40 | flag_hash: 49890eae64cefff4a47ad7975c319b813864dd1443205111c552795c194c56ac 41 | is_locked: true 42 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 43 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 44 | -------------------------------------------------------------------------------- /models/migrations/1489143364_initial_schema.down.sql: -------------------------------------------------------------------------------- 1 | -- VIEWS 2 | 3 | -- ---------------------- 4 | -- challenges_hints 5 | -- ---------------------- 6 | 7 | DROP VIEW IF EXISTS challenges_hints; 8 | 9 | -- ---------------------- 10 | -- challenges_likes 11 | -- ---------------------- 12 | 13 | DROP VIEW IF EXISTS challenges_likes; 14 | 15 | -- ---------------------- 16 | -- challenges_solutions 17 | -- ---------------------- 18 | 19 | DROP VIEW IF EXISTS challenges_solutions; 20 | 21 | -- TABLES 22 | 23 | -- --------------- 24 | -- settings 25 | -- --------------- 26 | 27 | DROP TABLE IF EXISTS settings; 28 | DROP TYPE IF EXISTS scoring_type; 29 | 30 | -- --------------- 31 | -- likes 32 | -- --------------- 33 | 34 | DROP TABLE IF EXISTS likes; 35 | 36 | -- --------------- 37 | -- announcements 38 | -- --------------- 39 | 40 | DROP TABLE IF EXISTS announcements; 41 | 42 | -- --------------- 43 | -- solutions 44 | -- --------------- 45 | 46 | DROP TABLE IF EXISTS solutions; 47 | 48 | -- --------------- 49 | -- challenges 50 | -- --------------- 51 | 52 | DROP TABLE IF EXISTS challenges; 53 | DROP TYPE IF EXISTS difficulty; 54 | 55 | -- --------------- 56 | -- tokens 57 | -- --------------- 58 | 59 | DROP TABLE IF EXISTS tokens; 60 | DROP TYPE IF EXISTS token_type; 61 | 62 | -- --------------- 63 | -- users 64 | -- --------------- 65 | 66 | DROP TABLE IF EXISTS users; 67 | -------------------------------------------------------------------------------- /web/public/src/scenes/components/UserStats.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | class UserStats extends Component { 6 | static propTypes = { 7 | userGetStats: PropTypes.func, 8 | userStats: PropTypes.object 9 | }; 10 | 11 | state = { 12 | intervalId: 0 13 | }; 14 | 15 | componentDidMount() { 16 | this.fetchData(); 17 | 18 | const intervalId = setInterval(this.fetchData, 30000); 19 | this.setState({ intervalId }); 20 | } 21 | 22 | componentWillUnmount() { 23 | clearInterval(this.state.intervalId); 24 | } 25 | 26 | fetchData = () => { 27 | const { userGetStats } = this.props; 28 | userGetStats(); 29 | }; 30 | 31 | render() { 32 | const { userStats } = this.props; 33 | const prefixClass = 'ctf-user-stats'; 34 | return ( 35 |
36 |
{userStats.rank}
37 |
{userStats.score}
38 |
39 | ); 40 | } 41 | } 42 | 43 | const mapStateToProps = state => ({ 44 | userStats: state.user.stats 45 | }); 46 | 47 | const mapDispatchToProps = dispatch => ({ 48 | userGetStats: dispatch.user.getStats 49 | }); 50 | 51 | export default connect( 52 | mapStateToProps, 53 | mapDispatchToProps 54 | )(UserStats); 55 | -------------------------------------------------------------------------------- /web/public/src/components/layout/Layout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | 5 | const generator = (props) => { 6 | return (BasicComponent) => { 7 | return class Adapter extends Component { 8 | render() { 9 | const { prefixClass } = props 10 | return 11 | } 12 | } 13 | } 14 | } 15 | 16 | class Basic extends Component { 17 | static propTypes = { 18 | className: PropTypes.string, 19 | prefixClass: PropTypes.string, 20 | children: PropTypes.node, 21 | } 22 | 23 | render() { 24 | const { prefixClass, className, children, ...others } = this.props 25 | const divClass = classNames(className, prefixClass) 26 | return ( 27 |
{children}
28 | ); 29 | } 30 | } 31 | 32 | const Layout = generator({ prefixClass: 'ctf-layout' })(Basic) 33 | const Header = generator({ prefixClass: 'ctf-layout-header' })(Basic) 34 | const Content = generator({ prefixClass: 'ctf-layout-content' })(Basic) 35 | const Container = generator({ prefixClass: 'ctf-layout-container' })(Basic) 36 | const Footer = generator({ prefixClass: 'ctf-layout-footer' })(Basic) 37 | 38 | Layout.Header = Header 39 | Layout.Content = Content 40 | Layout.Footer = Footer 41 | Layout.Container = Container 42 | 43 | export default Layout 44 | -------------------------------------------------------------------------------- /middlewares/csrf/token.go: -------------------------------------------------------------------------------- 1 | package csrf 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/subtle" 6 | "encoding/base64" 7 | "io" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | var tokenLen = 32 13 | 14 | func genToken() []byte { 15 | token := make([]byte, tokenLen) 16 | 17 | if _, err := io.ReadFull(rand.Reader, token); err != nil { 18 | panic(err) 19 | } 20 | 21 | return token 22 | } 23 | 24 | func compareTokens(a, b []byte) bool { 25 | return subtle.ConstantTimeCompare(a, b) == 1 26 | } 27 | 28 | func getSentToken(r *http.Request, header string) []byte { 29 | 30 | encoded := r.Header.Get(header) 31 | 32 | token, err := base64.URLEncoding.DecodeString(encoded) 33 | if err != nil { 34 | return nil 35 | } 36 | 37 | return token 38 | } 39 | 40 | func getRealToken(r *http.Request, cookieName string) []byte { 41 | 42 | cookie, err := r.Cookie(cookieName) 43 | if err != nil { 44 | return nil 45 | } 46 | 47 | token, err := base64.URLEncoding.DecodeString(cookie.Value) 48 | if err != nil { 49 | return nil 50 | } 51 | 52 | return token 53 | } 54 | 55 | func sendNewToken(w http.ResponseWriter, token []byte, opts *cookieOptions) { 56 | 57 | cookie := http.Cookie{ 58 | Name: opts.name, 59 | Value: base64.URLEncoding.EncodeToString(token), 60 | Expires: time.Now().UTC().Add(opts.lifetime), 61 | HttpOnly: opts.httponly, 62 | Secure: opts.secure, 63 | Path: "/", 64 | } 65 | 66 | http.SetCookie(w, &cookie) 67 | } 68 | -------------------------------------------------------------------------------- /models/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | name: LC↯BC 3 | email: lcbc@mail.com 4 | password_hash: '$2a$10$tp5jB2UF05bQq2TXxrgiE.QeNZM1Xws0ST8KOPjLe74lmRNFfZodO' 5 | extra: 6 | country: ru 7 | is_activated: true 8 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 9 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 10 | 11 | - id: 2 12 | name: PPP 13 | email: ppp@mail.com 14 | password_hash: '$2a$10$VVfUYQGziVC5RPUbFi6zQu8WbXxuQ2JlBNIpFfdzIayXNcxPX1R0K' 15 | extra: 16 | country: us 17 | is_activated: true 18 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 19 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 20 | 21 | - id: 3 22 | name: Dragon Sector 23 | email: dragonsector@mail.com 24 | password_hash: '$2a$10$lvbGv1KdoLe2uJUFmvZUN.Qcfbetvajwk4aOVG7DSUT/jSFEWf8Zm' 25 | extra: 26 | country: pl 27 | is_activated: true 28 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 29 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 30 | 31 | - id: 4 32 | name: '217' 33 | email: 217@mail.com 34 | password_hash: '$2a$10$9T8rX6PukjyGk21zO2jRyuxmibC.PoXdoefeLE3/yJKARGc.qTWra' 35 | extra: 36 | country: tw 37 | is_activated: false 38 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 39 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 40 | 41 | - id: 5 42 | name: 'TokyoWesterns' 43 | email: 'tokyowesterns@mail.com' 44 | password_hash: '$2a$10$Y9YU0OQV4/hjKrSXiQi5leO7/RRlbxr221jB9A0.vvGBNXpeONJN6' 45 | extra: 46 | country: jp 47 | is_activated: true 48 | created_at: RAW=NOW() AT TIME ZONE 'UTC' 49 | updated_at: RAW=NOW() AT TIME ZONE 'UTC' 50 | -------------------------------------------------------------------------------- /web/admin/src/models/announcements.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api' 2 | import parseLinkHeader from 'parse-link-header' 3 | import { stringify } from 'qs' 4 | 5 | export default { 6 | state: { 7 | items: [], 8 | item: {}, 9 | links: {}, 10 | }, 11 | reducers: { 12 | set: (state, payload) => { 13 | return { ...state, ...payload } 14 | }, 15 | }, 16 | effects: { 17 | async list({ link = null, filters = {} }) { 18 | const query = stringify(filters) 19 | const response = await api.get(link || query ? `announcements?${query}` : 'announcements') 20 | const links = parseLinkHeader(response.headers.link) || {} 21 | const items = response.data 22 | 23 | this.set({ items, links }) 24 | return items 25 | }, 26 | async get(announcementId) { 27 | const response = await api.get(`announcements/${announcementId}`) 28 | const item = response.data 29 | this.set({ item }) 30 | return item 31 | }, 32 | async delete(announcementId) { 33 | await api.delete(`announcements/${announcementId}`) 34 | }, 35 | async create(data) { 36 | const response = await api.post(`announcements`, data) 37 | const item = response.data 38 | this.set({ item }) 39 | return item 40 | }, 41 | async update(data) { 42 | const { id, ...rest } = data 43 | const response = await api.put(`announcements/${id}`, rest) 44 | const item = response.data 45 | this.set({ item }) 46 | return item 47 | }, 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/scenes/files-tree/FilesTree.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Tree } from 'antd' 4 | import { connect } from 'react-redux' 5 | 6 | class FilesTree extends Component { 7 | 8 | static propTypes = { 9 | files: PropTypes.object.isRequired, 10 | filesList: PropTypes.func.isRequired, 11 | } 12 | 13 | componentDidMount() { 14 | const { filesList } = this.props 15 | 16 | filesList() 17 | } 18 | 19 | renderNodes(nodes, keyPrefix) { 20 | const listNodes = nodes.map((node, i) => 21 | 26 | {this.renderNodes(node.items || [], `${keyPrefix}-${i}`)} 27 | , 28 | ) 29 | 30 | return listNodes 31 | } 32 | 33 | render() { 34 | const { files } = this.props.files 35 | 36 | return ( 37 |
38 |

Files

39 | 42 | {this.renderNodes(files, '0')} 43 | 44 |
45 | ) 46 | } 47 | } 48 | 49 | 50 | const mapStateToProps = (state) => ({ 51 | files: state.files, 52 | filesListResult: state.api.effects.files.list, 53 | }); 54 | 55 | const mapDispatchToProps = (dispatch) => ({ 56 | filesList: dispatch.files.list, 57 | }); 58 | 59 | export default connect(mapStateToProps, mapDispatchToProps)(FilesTree) 60 | -------------------------------------------------------------------------------- /web/public/src/scenes/scenes/auth/Activate.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter, Link } from 'react-router-dom'; 5 | 6 | import { Loading, Window } from '../../../components'; 7 | 8 | class Login extends Component { 9 | static propTypes = { 10 | authActivate: PropTypes.func, 11 | match: PropTypes.object, 12 | history: PropTypes.object 13 | }; 14 | 15 | state = { 16 | success: false 17 | }; 18 | 19 | async componentDidMount() { 20 | const { authActivate, history } = this.props; 21 | const { token } = this.props.match.params; 22 | try { 23 | await authActivate({ token }); 24 | this.setState({ success: true }); 25 | } catch (e) { 26 | history.push('/auth/login'); 27 | } 28 | } 29 | 30 | render() { 31 | const { success } = this.state; 32 | 33 | return ( 34 | 35 | 36 |
37 | Your account was activated. Now you can log in. 38 |
39 |
40 | Log In 41 |
42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | const mapDispatchToProps = dispatch => ({ 49 | authActivate: dispatch.auth.activate 50 | }); 51 | 52 | export default withRouter( 53 | connect( 54 | () => ({}), 55 | mapDispatchToProps 56 | )(Login) 57 | ); 58 | -------------------------------------------------------------------------------- /web/admin/src/images/logo-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Slice 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /web/admin/src/models/challenges.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api' 2 | import parseLinkHeader from 'parse-link-header' 3 | import { stringify } from 'qs' 4 | 5 | export default { 6 | state: { 7 | challenges: [], 8 | challenge: {}, 9 | links: {}, 10 | }, 11 | reducers: { 12 | set: (state, payload) => { 13 | return { ...state, ...payload } 14 | }, 15 | }, 16 | effects: { 17 | async list({ link = null, filters = {} }) { 18 | const query = stringify(filters, { allowDots: true, arrayFormat: 'repeat' }) 19 | const response = await api.get(link || 'challenges?' + query) 20 | const links = parseLinkHeader(response.headers.link) || {} 21 | const challenges = response.data 22 | 23 | this.set({ challenges, links }) 24 | return challenges 25 | }, 26 | async get(challengeId) { 27 | const response = await api.get(`challenges/${challengeId}`) 28 | const challenge = response.data 29 | this.set({ challenge }) 30 | return challenge 31 | }, 32 | async delete(challengeId) { 33 | await api.delete(`challenges/${challengeId}`) 34 | }, 35 | async create(data) { 36 | const response = await api.post(`challenges`, data) 37 | const challenge = response.data 38 | this.set({ challenge }) 39 | return challenge 40 | }, 41 | async update(data) { 42 | const { id, ...rest } = data 43 | const response = await api.put(`challenges/${id}`, rest) 44 | const challenge = response.data 45 | this.set({ challenge }) 46 | return challenge 47 | }, 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /web/public/src/scenes/scenes/rules/Rules.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | 4 | import { Page } from '../../../components'; 5 | 6 | // TODO: get from API 7 | const rules = ` 8 | Online-stage, starts on 20.10.2018 at 09:00 UTC and will last for 24 hours. 9 | Stage format – Jeopardy. Top 10 teams will be invited to the final. 10 | 11 | Rules: 12 | 13 | - register; 14 | - tasks will be available at the link; 15 | - those who earn more points wins the CTF; 16 | - in case of equal points, the winner will be the one who earned them first. 17 | 18 | It's forbidden to: 19 | 20 | - attack the organizer's infrastructure; 21 | - generate large amounts of traffic (DDoS); 22 | - attack computers of the jury or other participants; 23 | - share flags with other participants. 24 | 25 | The amount of points that every team gets for each task depends on how many times this task was solved by all teams. 26 | 27 | Each task is marked as easy, medium or hard. 28 | This difficulty level doesn’t affect scoring formula of the task, 29 | which only depends on the amount of teams that has submitted the flag. 30 | 31 | Flag format: \`mctf{[a-f0-9]{32}}\` 32 | 33 | The organizers reserve the right to disqualify participants for violating the rules. 34 | `; 35 | 36 | class Rules extends Component { 37 | static propTypes = {}; 38 | 39 | componentDidMount() {} 40 | 41 | render() { 42 | return ( 43 | 44 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | export default Rules; 51 | -------------------------------------------------------------------------------- /config/helpers.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | ) 10 | 11 | func (c Game) StartTime() time.Time { 12 | t, err := time.Parse(time.RFC3339, c.Start) 13 | if err != nil { 14 | panic(err) 15 | } 16 | return t 17 | } 18 | 19 | func (c Game) EndTime() time.Time { 20 | t, err := time.Parse(time.RFC3339, c.End) 21 | if err != nil { 22 | panic(err) 23 | } 24 | return t 25 | } 26 | 27 | func (c Session) SecretBytes() []byte { 28 | b, err := hex.DecodeString(c.Secret) 29 | if err != nil { 30 | panic(err) 31 | } 32 | return b 33 | } 34 | 35 | func (c Server) BaseURL() string { 36 | url := "" 37 | 38 | if c.TLS.Enabled { 39 | url += "https://" 40 | } else { 41 | url += "http://" 42 | } 43 | 44 | url += c.Domain 45 | 46 | if c.IncludePort { 47 | if c.TLS.Enabled { 48 | url += fmt.Sprintf(":%d", c.TLS.Port) 49 | } else { 50 | url += fmt.Sprintf(":%d", c.Port) 51 | } 52 | } 53 | 54 | return url 55 | } 56 | 57 | func (c Server) AdminBaseURL() string { 58 | url := "" 59 | 60 | if c.TLS.Enabled { 61 | url += "https://" 62 | } else { 63 | url += "http://" 64 | } 65 | 66 | url += fmt.Sprintf("admin.%s", c.Domain) 67 | 68 | if c.IncludePort { 69 | if c.TLS.Enabled { 70 | url += fmt.Sprintf(":%d", c.TLS.Port) 71 | } else { 72 | url += fmt.Sprintf(":%d", c.Port) 73 | } 74 | } 75 | 76 | return url 77 | } 78 | 79 | func (c Files) Dir() string { 80 | if filepath.IsAbs(c.Path) { 81 | return c.Path 82 | } 83 | 84 | workDir, _ := os.Getwd() 85 | 86 | return filepath.Join(workDir, c.Path) 87 | } 88 | -------------------------------------------------------------------------------- /models/likes_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | . "github.com/ctf-zone/ctfzone/models" 11 | ) 12 | 13 | func Test_Likes_Insert_Success(t *testing.T) { 14 | setup(t) 15 | defer teardown(t) 16 | 17 | l := &Like{ 18 | UserID: 1, 19 | ChallengeID: 2, 20 | } 21 | 22 | err := db.LikesInsert(l) 23 | assert.NoError(t, err) 24 | 25 | assert.WithinDuration(t, time.Now().UTC(), l.CreatedAt, 5*time.Second) 26 | } 27 | 28 | func Test_Likes_Insert_Duplicate(t *testing.T) { 29 | setup(t) 30 | defer teardown(t) 31 | 32 | l := &Like{ 33 | UserID: 1, 34 | ChallengeID: 1, 35 | } 36 | 37 | err := db.LikesInsert(l) 38 | assert.Error(t, err) 39 | } 40 | 41 | func Test_Likes_Delete_Success(t *testing.T) { 42 | setup(t) 43 | defer teardown(t) 44 | 45 | err := db.LikesDelete(1, 1) 46 | assert.NoError(t, err) 47 | } 48 | 49 | func Test_Likes_Delete_NotExist(t *testing.T) { 50 | setup(t) 51 | defer teardown(t) 52 | 53 | err := db.LikesDelete(1337, 1337) 54 | assert.Error(t, err) 55 | assert.EqualError(t, err, sql.ErrNoRows.Error()) 56 | } 57 | 58 | func Test_Likes_OneByID_Success(t *testing.T) { 59 | setup(t) 60 | defer teardown(t) 61 | 62 | s, err := db.LikesOneByID(1, 1) 63 | assert.NoError(t, err) 64 | assert.NotNil(t, s) 65 | } 66 | 67 | func Test_Likes_OneByID_NotExist(t *testing.T) { 68 | setup(t) 69 | defer teardown(t) 70 | 71 | s, err := db.LikesOneByID(1, 1337) 72 | assert.Error(t, err) 73 | assert.Nil(t, s) 74 | assert.EqualError(t, err, sql.ErrNoRows.Error()) 75 | } 76 | -------------------------------------------------------------------------------- /middlewares/recaptcha/check.go: -------------------------------------------------------------------------------- 1 | package recaptcha 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | // See https://developers.google.com/recaptcha/docs/verify 11 | var ( 12 | checkURL = "https://www.google.com/recaptcha/api/siteverify" 13 | errorText = map[string]string{ 14 | "missing-input-secret": "The secret parameter is missing", 15 | "invalid-input-secret": "The secret parameter is invalid or malformed", 16 | "missing-input-response": "The response parameter is missing", 17 | "invalid-input-response": "The response parameter is invalid or malformed", 18 | } 19 | ) 20 | 21 | type recaptchaErrors []string 22 | 23 | func (re recaptchaErrors) Error() string { 24 | var err string 25 | for _, e := range re { 26 | err += errorText[e] + "; " 27 | } 28 | return err 29 | } 30 | 31 | type recaptchaResponse struct { 32 | Success bool `json:"success"` 33 | Challenge time.Time `json:"challenge_ts"` 34 | Hostname string `json:"hostname"` 35 | Errors recaptchaErrors `json:"error-codes"` 36 | } 37 | 38 | func check(secret, clientResponse, clientIP string) error { 39 | 40 | r, err := http.PostForm(checkURL, url.Values{ 41 | "secret": {secret}, 42 | "response": {clientResponse}, 43 | "remoteip": {clientIP}, 44 | }) 45 | 46 | if err != nil { 47 | return err 48 | } 49 | 50 | defer r.Body.Close() 51 | 52 | var res recaptchaResponse 53 | err = json.NewDecoder(r.Body).Decode(&res) 54 | 55 | if err != nil { 56 | return err 57 | } 58 | 59 | if !res.Success { 60 | return res.Errors 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /middlewares/csrf/options.go: -------------------------------------------------------------------------------- 1 | package csrf 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | var defaultOptions = &options{ 9 | header: "X-CSRF-Token", 10 | cookie: &cookieOptions{ 11 | name: "csrf-token", 12 | secure: true, 13 | lifetime: time.Hour * 24, 14 | httponly: false, 15 | }, 16 | errorFunc: defaultErrorFunc, 17 | } 18 | 19 | type cookieOptions struct { 20 | name string 21 | secure bool 22 | lifetime time.Duration 23 | httponly bool 24 | } 25 | 26 | type options struct { 27 | header string 28 | cookie *cookieOptions 29 | errorFunc func(http.ResponseWriter, *http.Request, error) 30 | } 31 | 32 | // Option defines the functional arguments for configuring the middleware. 33 | type Option func(*options) 34 | 35 | // Header sets the header name from which the CSRF-token will be taken. 36 | // Default value is "X-CSRF-Token". 37 | func Header(s string) Option { 38 | return func(opts *options) { 39 | opts.header = s 40 | } 41 | } 42 | 43 | func CookieName(s string) Option { 44 | return func(opts *options) { 45 | opts.cookie.name = s 46 | } 47 | } 48 | 49 | func CookieSecure(b bool) Option { 50 | return func(opts *options) { 51 | opts.cookie.secure = b 52 | } 53 | } 54 | 55 | func CookieLifetime(t time.Duration) Option { 56 | return func(opts *options) { 57 | opts.cookie.lifetime = t 58 | } 59 | } 60 | 61 | func CookieHTTPOnly(b bool) Option { 62 | return func(opts *options) { 63 | opts.cookie.httponly = b 64 | } 65 | } 66 | 67 | func ErrorFunc(f func(http.ResponseWriter, *http.Request, error)) Option { 68 | return func(opts *options) { 69 | opts.errorFunc = f 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY = ctfzone 2 | 3 | pkgs = $(shell go list ./...) 4 | 5 | build: 6 | @echo ">> building binaries" 7 | @go build -ldflags "-s -w" -o ${BINARY} 8 | 9 | clean: 10 | @echo ">> cleaning binaries" 11 | @if [ -f ${BINARY} ]; then rm ${BINARY}; fi 12 | 13 | format: 14 | @echo ">> formatting code" 15 | @go fmt ${pkgs} 16 | 17 | test: 18 | @echo ">> running all tests" 19 | @go test -p 1 ${pkgs} 20 | 21 | tools: 22 | @echo ">> install nesessary tools" 23 | @go get -u github.com/jteeuwen/go-bindata/... 24 | @go get -u github.com/vektra/mockery/... 25 | 26 | pack-schemas: 27 | @echo ">> writing json-schemas" 28 | @go-bindata -pkg schemas \ 29 | -ignore ".*\.go" \ 30 | -prefix "controllers/schemas" \ 31 | -o controllers/schemas/bindata.go \ 32 | controllers/schemas/... 33 | @go fmt ./controllers/schemas 34 | 35 | pack-migrations: 36 | @echo ">> writing migrations" 37 | @go-bindata -pkg migrations \ 38 | -ignore ".*\.go" \ 39 | -prefix "models/migrations" \ 40 | -o models/migrations/bindata.go \ 41 | models/migrations/... 42 | @go fmt ./models/migrations 43 | 44 | mailer-mock: 45 | @echo ">> generating mailer mock" 46 | @mockery -dir modules/mailer \ 47 | -output modules/mailer/mock \ 48 | -outpkg mailer_mock \ 49 | -name Sender 50 | @go fmt ./modules/mailer/mock 51 | 52 | db-clean: 53 | @echo ">> cleaning database" 54 | @psql ${CTF_DB_DSN} -c "DROP OWNED BY ctfzone" 55 | 56 | migrations-up: 57 | @echo ">> migrations up" 58 | @migrate -database ${CTF_DB_DSN} -path ./models/migrations up 59 | 60 | migrations-down: 61 | @echo ">> migrations down" 62 | @migrate -database ${CTF_DB_DSN} -path ./models/migrations down 63 | -------------------------------------------------------------------------------- /middlewares/csrf/handler.go: -------------------------------------------------------------------------------- 1 | package csrf 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | ) 7 | 8 | var safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"} 9 | 10 | type handler struct { 11 | next http.Handler 12 | opts *options 13 | } 14 | 15 | // contains returns true if string is in slice. 16 | func contains(vals []string, s string) bool { 17 | for _, v := range vals { 18 | if v == s { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 26 | real := getRealToken(r, h.opts.cookie.name) 27 | if len(real) != tokenLen { 28 | token := genToken() 29 | sendNewToken(w, token, h.opts.cookie) 30 | r = addTokenToRequest(r, token) 31 | } else { 32 | r = addTokenToRequest(r, real) 33 | } 34 | 35 | if !contains(safeMethods, r.Method) { 36 | sent := getSentToken(r, h.opts.header) 37 | 38 | if real == nil || !compareTokens(real, sent) { 39 | h.opts.errorFunc(w, r, errors.New("tokens do not match")) 40 | return 41 | } 42 | } 43 | 44 | // Set the "Vary: Cookie" header to protect clients from caching the response. 45 | w.Header().Add("Vary", "Cookie") 46 | 47 | h.next.ServeHTTP(w, r) 48 | } 49 | 50 | func defaultErrorFunc(w http.ResponseWriter, r *http.Request, err error) { 51 | http.Error(w, "Wrong CSRF-token", 403) 52 | } 53 | 54 | func New(opts ...Option) func(http.Handler) http.Handler { 55 | return func(next http.Handler) http.Handler { 56 | do := *defaultOptions 57 | 58 | h := &handler{ 59 | next: next, 60 | opts: &do, 61 | } 62 | 63 | for _, f := range opts { 64 | f(h.opts) 65 | } 66 | 67 | return h 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /web/admin/src/images/logo-big.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /controllers/challenges_test.go: -------------------------------------------------------------------------------- 1 | package controllers_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/ctf-zone/ctfzone/controllers" 7 | ) 8 | 9 | func TestChallengesList(t *testing.T) { 10 | setup(t) 11 | defer teardown(t) 12 | 13 | e := heDefault(t) 14 | e = heHost(e, "ctfzone.test") 15 | e = heCSRF(e) 16 | e = heAuth(e, "lcbc@mail.com", "lcbc") 17 | 18 | res := e.GET("/api/challenges"). 19 | Expect(). 20 | Status(200) 21 | 22 | checkJSONSchema(t, "Challenges.json", res.Body().Raw()) 23 | 24 | res.JSON().Array().Length().Equal(3) 25 | } 26 | 27 | func TestChallengesGet_InvalidID(t *testing.T) { 28 | setup(t) 29 | defer teardown(t) 30 | 31 | e := heDefault(t) 32 | e = heHost(e, "ctfzone.test") 33 | e = heCSRF(e) 34 | e = heAuth(e, "lcbc@mail.com", "lcbc") 35 | 36 | res := e.GET("/api/challenges/"). 37 | Expect(). 38 | Status(400) 39 | 40 | res.JSON().Path("$.error").String().Equal(ErrInvalidID.Msg) 41 | } 42 | 43 | func TestChallengesGet_NotExistingChallenge(t *testing.T) { 44 | setup(t) 45 | defer teardown(t) 46 | 47 | e := heDefault(t) 48 | e = heHost(e, "ctfzone.test") 49 | e = heCSRF(e) 50 | e = heAuth(e, "lcbc@mail.com", "lcbc") 51 | 52 | res := e.GET("/api/challenges/1337"). 53 | Expect(). 54 | Status(404) 55 | 56 | res.JSON().Path("$.error").String().Equal(ErrChallengeNotFound.Msg) 57 | } 58 | 59 | func TestChallengesGet_Success(t *testing.T) { 60 | setup(t) 61 | defer teardown(t) 62 | 63 | e := heDefault(t) 64 | e = heHost(e, "ctfzone.test") 65 | e = heCSRF(e) 66 | e = heAuth(e, "lcbc@mail.com", "lcbc") 67 | 68 | res := e.GET("/api/challenges/1"). 69 | Expect(). 70 | Status(200) 71 | 72 | checkJSONSchema(t, "ChallengeE.json", res.Body().Raw()) 73 | } 74 | -------------------------------------------------------------------------------- /web/public/src/scenes/scenes/auth/Auth.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withRouter, Switch, Route } from 'react-router-dom'; 4 | 5 | import { NotFound } from '../../../components'; 6 | import Login from './Login'; 7 | import Signup from './Signup'; 8 | import Logout from './Logout'; 9 | import SendToken from './SendToken'; 10 | import ResetPassword from './ResetPassword'; 11 | import Activate from './Activate'; 12 | 13 | class Auth extends Component { 14 | static propTypes = { 15 | match: PropTypes.object, 16 | history: PropTypes.object 17 | }; 18 | 19 | componentDidMount() { 20 | const { match, history } = this.props; 21 | 22 | if (match.isExact) { 23 | history.push('/auth/login'); 24 | } 25 | } 26 | 27 | render() { 28 | const { match } = this.props; 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 40 | 45 | 50 | 51 | 52 | ); 53 | } 54 | } 55 | 56 | export default withRouter(Auth); 57 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/components/nav/Nav.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Link } from "react-router-dom"; 4 | import { Menu, Icon } from "antd"; 5 | import { withRouter } from "react-router"; 6 | 7 | class Nav extends Component { 8 | static propTypes = { 9 | location: PropTypes.object, 10 | }; 11 | 12 | 13 | render() { 14 | const { location } = this.props; 15 | 16 | const menuItems = [ 17 | { 18 | title: 'Users', 19 | path: '/users', 20 | icon: 'team', 21 | }, 22 | { 23 | title: 'Challenges', 24 | path: '/challenges', 25 | icon: 'bulb', 26 | }, 27 | { 28 | title: 'Announcements', 29 | path: '/announcements', 30 | icon: 'notification', 31 | }, 32 | { 33 | title: 'Files', 34 | path: '/files', 35 | icon: 'file', 36 | }, 37 | ] 38 | 39 | let selectedKeys = []; 40 | menuItems.forEach((m, i) => { 41 | if (m.path === "/") { 42 | if (location.pathname === "/") selectedKeys.push(i.toString()); 43 | return; 44 | } 45 | 46 | if (location.pathname.startsWith(m.path)) selectedKeys.push(i.toString()); 47 | }); 48 | 49 | return ( 50 | 51 | {menuItems.map((item, i) => ( 52 | 53 | 54 | 55 | {item.title} 56 | 57 | 58 | ))} 59 | 60 | ); 61 | } 62 | } 63 | 64 | export default withRouter(Nav); 65 | -------------------------------------------------------------------------------- /web/admin/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | CTFZone Admin Panel 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /web/admin/src/models/users.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api' 2 | import parseLinkHeader from 'parse-link-header' 3 | import { stringify } from 'qs' 4 | 5 | export default { 6 | state: { 7 | users: [], 8 | user: {}, 9 | links: {}, 10 | }, 11 | reducers: { 12 | set: (state, payload) => { 13 | return { ...state, ...payload } 14 | }, 15 | }, 16 | effects: { 17 | async list({ link = null, filters = {} }) { 18 | const { extra, ...restFilters } = filters 19 | 20 | let extraQuery = Object. 21 | entries(extra || {}). 22 | reduce((acc, v) => acc += `${v[0]}:${v[1].join(',')};`, '') 23 | 24 | let query = extraQuery.length !== 0 25 | ? `extra=${extraQuery}&` 26 | : '' 27 | 28 | query += stringify(restFilters, { allowDots: true }) 29 | 30 | const response = await api.get(link || 'users?' + query) 31 | const links = parseLinkHeader(response.headers.link) || {} 32 | 33 | const users = response.data 34 | this.set({ users, links }) 35 | 36 | return users 37 | }, 38 | async get(userId) { 39 | const response = await api.get(`users/${userId}`) 40 | const user = response.data 41 | this.set({ user }) 42 | return user 43 | }, 44 | async delete(userId) { 45 | await api.delete(`users/${userId}`) 46 | }, 47 | async create(data) { 48 | const response = await api.post(`users`, data) 49 | const user = response.data 50 | this.set({ user }) 51 | return user 52 | }, 53 | async update({ id, ...rest }) { 54 | const response = await api.put(`users/${id}`, rest) 55 | const user = response.data 56 | this.set({ user }) 57 | return user 58 | }, 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /controllers/announcements.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/ctf-zone/ctfzone/models" 8 | "github.com/go-chi/chi" 9 | ) 10 | 11 | // AnnouncementsList returns all announcements 12 | func AnnouncementsList(db *models.Repository) http.HandlerFunc { 13 | 14 | type Params struct { 15 | models.AnnouncementsFilters 16 | models.Pagination 17 | } 18 | 19 | return func(w http.ResponseWriter, r *http.Request) { 20 | var params Params 21 | 22 | if err := decoder.Decode(¶ms, r.URL.Query()); err != nil { 23 | handleError(w, r, ErrInvalidQueryParams.SetMessage(err.Error())) 24 | return 25 | } 26 | 27 | announcements, _, err := db.AnnouncementsList( 28 | models.AnnouncementsFilter(params.AnnouncementsFilters), 29 | models.AnnouncementsPagination(params.Pagination), 30 | ) 31 | if err != nil { 32 | handleError(w, r, ErrInternal.SetError(err)) 33 | return 34 | } 35 | 36 | // schema: Announcements 37 | if err := responseJSON(w, announcements); err != nil { 38 | handleError(w, r, ErrInternal.SetError(err)) 39 | return 40 | } 41 | } 42 | } 43 | 44 | func AnnouncementsGet(db *models.Repository) http.HandlerFunc { 45 | return func(w http.ResponseWriter, r *http.Request) { 46 | announcementID, err := strconv.ParseInt(chi.URLParam(r, "announcementId"), 10, 32) 47 | if err != nil { 48 | handleError(w, r, ErrInvalidID) 49 | return 50 | } 51 | 52 | announcement, err := db.AnnouncementsOneByID(announcementID) 53 | if err != nil { 54 | handleError(w, r, ErrInternal.SetError(err)) 55 | return 56 | } 57 | 58 | if err := responseJSON(w, announcement); err != nil { 59 | handleError(w, r, ErrInternal.SetError(err)) 60 | return 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /middlewares/schemacheck/handler.go: -------------------------------------------------------------------------------- 1 | package schemacheck 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | 8 | "github.com/xeipuuv/gojsonschema" 9 | ) 10 | 11 | type handler struct { 12 | next http.Handler 13 | opts *options 14 | } 15 | 16 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 17 | 18 | // Load request body. 19 | body, err := ioutil.ReadAll(r.Body) 20 | if err != nil { 21 | h.opts.errorFunc(w, r, &Error{Msg: "invalid json"}) 22 | return 23 | } 24 | 25 | // Validate schema. 26 | res, err := h.opts.schema.Validate(gojsonschema.NewBytesLoader(body)) 27 | if err != nil { 28 | h.opts.errorFunc(w, r, &Error{Msg: "invalid json"}) 29 | return 30 | } 31 | 32 | // Check validation successful. 33 | if !res.Valid() { 34 | e := &Error{Msg: "validation failed"} 35 | for _, err := range res.Errors() { 36 | e.Errs = append(e.Errs, &FieldError{Field: err.Field(), Msg: err.Description()}) 37 | } 38 | 39 | h.opts.errorFunc(w, r, e) 40 | return 41 | } 42 | 43 | // Restore body 44 | r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) 45 | 46 | h.next.ServeHTTP(w, r) 47 | } 48 | 49 | func defaultErrorFunc(w http.ResponseWriter, r *http.Request, err error) { 50 | http.Error(w, "Bad request", 400) 51 | } 52 | 53 | func New(schema gojsonschema.JSONLoader, opts ...Option) func(http.Handler) http.Handler { 54 | return func(next http.Handler) http.Handler { 55 | do := *defaultOptions 56 | 57 | // Load schema 58 | s, err := gojsonschema.NewSchema(schema) 59 | 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | do.schema = s 65 | 66 | h := &handler{ 67 | next: next, 68 | opts: &do, 69 | } 70 | 71 | for _, fn := range opts { 72 | fn(h.opts) 73 | } 74 | 75 | return h 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /templates/email/ext/reset.tmpl: -------------------------------------------------------------------------------- 1 | {{define "meta"}} 2 | { 3 | "subject": "Reset password", 4 | "from": "MCTF " 5 | } 6 | {{end}} 7 | 8 | {{define "content"}} 9 | 10 | 11 | MCTF reset password 12 | 13 | 14 | 15 | 16 | 42 | 43 | 44 | 45 |
17 | 18 | 19 | 39 | 40 |
20 |

Hello {{ .Name }}!

21 |

Click on button below to reset your password

22 | 23 | 24 | 25 | 34 | 35 | 36 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
Reset password
33 |
37 |

Please do not reply to this e-mail.

38 |
41 |
46 | 47 | 48 | 57 | 58 | 59 | {{end}} 60 | -------------------------------------------------------------------------------- /web/public/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /web/admin/src/scenes/scenes/main/components/sidebar/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Layout, Icon } from 'antd' 4 | 5 | import logoBig from '../../../../../images/logo-big.svg' 6 | import logoSmall from '../../../../../images/logo-small.svg' 7 | 8 | import styles from './Sidebar.module.css' 9 | 10 | class Sidebar extends Component { 11 | 12 | static propTypes = { 13 | children: PropTypes.node, 14 | } 15 | 16 | state = { 17 | collapsed: false, 18 | } 19 | 20 | toggleSiderbar = () => { 21 | const { collapsed } = this.state 22 | 23 | this.setState({ 24 | collapsed: !collapsed, 25 | }) 26 | } 27 | 28 | render() { 29 | const { collapsed } = this.state 30 | const { children } = this.props 31 | 32 | const logo = collapsed 33 | ? logoSmall 34 | : logoBig 35 | 36 | const toggleIcon = collapsed ? 'right' : 'left' 37 | 38 | return ( 39 | 43 | 46 | 47 |
48 | logo 49 |
50 |
51 | 52 | {children} 53 | 54 | 55 |
59 | 60 |
61 |
62 |
63 |
64 | ) 65 | } 66 | } 67 | 68 | export default Sidebar 69 | -------------------------------------------------------------------------------- /controllers/admin_files.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path" 10 | 11 | "github.com/ctf-zone/ctfzone/config" 12 | ) 13 | 14 | func AdminFilesUpload(cfg *config.Files, baseURL string) http.HandlerFunc { 15 | 16 | type Response struct { 17 | URL string `json:"url"` 18 | } 19 | 20 | return func(w http.ResponseWriter, r *http.Request) { 21 | r.ParseMultipartForm(cfg.MaxSize) 22 | 23 | file, hdr, err := r.FormFile("file") 24 | if err != nil { 25 | handleError(w, r, ErrInternal.SetError(err)) 26 | return 27 | } 28 | defer file.Close() 29 | 30 | h := sha256.New() 31 | if _, err := io.Copy(h, file); err != nil { 32 | handleError(w, r, ErrInternal.SetError(err)) 33 | return 34 | } 35 | 36 | fpath := path.Join(cfg.Path, path.Base( 37 | fmt.Sprintf("%s.%x", hdr.Filename, h.Sum(nil)), 38 | )) 39 | 40 | file.Seek(0, os.SEEK_SET) 41 | 42 | f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE, 0666) 43 | if err != nil { 44 | handleError(w, r, ErrInternal.SetError(err)) 45 | return 46 | } 47 | defer f.Close() 48 | 49 | if _, err := io.Copy(f, file); err != nil { 50 | handleError(w, r, ErrInternal.SetError(err)) 51 | return 52 | } 53 | 54 | res := &Response{ 55 | URL: fmt.Sprintf("%s/%s", baseURL, fpath), 56 | } 57 | 58 | if err := responseJSON(w, res); err != nil { 59 | handleError(w, r, ErrInternal.SetError(err)) 60 | } 61 | } 62 | } 63 | 64 | func AdminFilesGetAll(c *config.Files) http.HandlerFunc { 65 | 66 | return func(w http.ResponseWriter, r *http.Request) { 67 | 68 | files, err := getDirFiles(c.Path) 69 | if err != nil { 70 | handleError(w, r, ErrInternal.SetError(err)) 71 | return 72 | } 73 | 74 | if err := responseJSON(w, files); err != nil { 75 | handleError(w, r, ErrInternal.SetError(err)) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /templates/email/ext/activate.tmpl: -------------------------------------------------------------------------------- 1 | {{define "meta"}} 2 | { 3 | "subject": "Account activation", 4 | "from": "MCTF " 5 | } 6 | {{end}} 7 | 8 | {{define "content"}} 9 | 10 | 11 | MCTF account confirmation 12 | 13 | 14 | 15 | 16 | 42 | 43 | 44 | 45 |
17 | 18 | 19 | 39 | 40 |
20 |

Hello {{ .Name }}!

21 |

Thanks for participating in our CTF! By clicking on the link below to activate your account, you accept our rules

22 | 23 | 24 | 25 | 34 | 35 | 36 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
Activate
33 |
37 |

Please do not reply to this e-mail.

38 |
41 |
46 | 47 | 48 | 57 | 58 | 59 | {{end}} 60 | -------------------------------------------------------------------------------- /models/models_test.go: -------------------------------------------------------------------------------- 1 | package models_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "testing" 8 | 9 | "github.com/go-testfixtures/testfixtures" 10 | "github.com/jmoiron/sqlx" 11 | _ "github.com/lib/pq" 12 | "github.com/pkg/errors" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/ctf-zone/ctfzone/models" 16 | . "github.com/ctf-zone/ctfzone/models" 17 | "github.com/ctf-zone/ctfzone/models/migrations" 18 | ) 19 | 20 | var ( 21 | db *Repository 22 | dbx *sqlx.DB 23 | fixtures *testfixtures.Context 24 | ) 25 | 26 | func TestMain(m *testing.M) { 27 | if err := setupGlobals(); err != nil { 28 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 29 | os.Exit(1) 30 | } 31 | 32 | ret := m.Run() 33 | 34 | if err := teardownGlobals(); err != nil { 35 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 36 | os.Exit(1) 37 | } 38 | 39 | os.Exit(ret) 40 | } 41 | 42 | func setupGlobals() error { 43 | dsn, ok := os.LookupEnv("CTF_DB_DSN") 44 | if !ok { 45 | return errors.New("empty CTF_DB_DSN") 46 | } 47 | 48 | var err error 49 | 50 | db, err = models.New(dsn) 51 | if err != nil { 52 | return errors.Wrap(err, "fail to init models") 53 | } 54 | 55 | dbx, err = sqlx.Connect("postgres", dsn) 56 | if err != nil { 57 | return errors.Wrap(err, "fail to connect database") 58 | } 59 | 60 | if err := migrations.Up(dsn); err != nil { 61 | log.Fatal(err) 62 | } 63 | 64 | fixtures, err = testfixtures.NewFolder(dbx.DB, 65 | &testfixtures.PostgreSQL{}, "fixtures") 66 | if err != nil { 67 | return errors.Wrap(err, "fail to load fixtures") 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func teardownGlobals() error { 74 | if db != nil { 75 | if err := db.Close(); err != nil { 76 | return errors.Wrap(err, "fail to close connection to database") 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | func setup(t *testing.T) { 83 | err := fixtures.Load() 84 | require.NoError(t, err) 85 | } 86 | 87 | func teardown(t *testing.T) {} 88 | -------------------------------------------------------------------------------- /web/admin/src/components/filters/withFilters.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import * as _ from 'lodash' 3 | 4 | import { set, unset } from '../../utils/object' 5 | 6 | export default (WrappedComponent) => { 7 | return class WithFilters extends Component { 8 | state = { 9 | filters: {}, 10 | resetFilters: {}, 11 | } 12 | 13 | static defaultState = { 14 | filters: {}, 15 | resetFilters: {}, 16 | } 17 | 18 | isFiltered = (path) => { 19 | return _.has(this.state.filters, path) 20 | } 21 | 22 | handleFilterSet = (path, value, confirm, clearFilters) => () => { 23 | confirm() 24 | 25 | const resetFilters = { ...this.state.resetFilters } 26 | 27 | if (!([path] in resetFilters)) { 28 | resetFilters[path] = clearFilters 29 | } 30 | 31 | this.setState({ 32 | filters: set(path, value, this.state.filters), 33 | resetFilters, 34 | }) 35 | } 36 | 37 | handleFilterReset = (path, clearFilters) => () => { 38 | clearFilters() 39 | 40 | const resetFilters = { ...this.state.resetFilters } 41 | 42 | if ([path] in resetFilters) { 43 | delete resetFilters[path] 44 | } 45 | 46 | this.setState({ 47 | filters: unset(path, this.state.filters), 48 | resetFilters, 49 | }) 50 | } 51 | 52 | handleResetFilters = () => { 53 | Object.values(this.state.resetFilters). 54 | forEach((clear) => clear()) 55 | 56 | this.setState({ 57 | filters: {}, 58 | }) 59 | } 60 | 61 | render() { 62 | const props = { 63 | ...this.props, 64 | filters: this.state.filters, 65 | handleFilterSet: this.handleFilterSet, 66 | handleFilterReset: this.handleFilterReset, 67 | handleResetFilters: this.handleResetFilters, 68 | isFiltered: this.isFiltered, 69 | } 70 | 71 | return 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /web/public/src/scenes/scenes/challenge/FlagForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { createForm } from 'rc-form'; 4 | 5 | import { FormItem, Button } from '../../../components'; 6 | 7 | class FlagForm extends Component { 8 | static propTypes = { 9 | onSubmit: PropTypes.func.isRequired, 10 | form: PropTypes.object.isRequired, 11 | fields: PropTypes.object, 12 | className: PropTypes.string 13 | }; 14 | 15 | handleSubmit = e => { 16 | e.preventDefault(); 17 | 18 | const { getFieldsValue } = this.props.form; 19 | this.props.onSubmit(getFieldsValue()); 20 | }; 21 | 22 | renderFlagField({ getFieldDecorator, getFieldError }) { 23 | return ( 24 | 25 | {getFieldDecorator('flag', { 26 | initialValue: '', 27 | rules: [ 28 | { 29 | required: true, 30 | message: 'Flag is required' 31 | } 32 | ] 33 | })()} 34 | 35 | ); 36 | } 37 | 38 | renderSubmitButton({ getFieldError, isFieldTouched }) { 39 | const canSubmit = ['flag'].reduce((result, field) => { 40 | return result && isFieldTouched(field) && !getFieldError(field); 41 | }, true); 42 | 43 | return ( 44 | 45 |