Skip to content

Commit c8061aa

Browse files
authored
Merge branch 'master' into dependabot/hex/ex_aws_s3-2.5.2
2 parents d47fe74 + 0572fb0 commit c8061aa

22 files changed

+291
-46
lines changed

.github/workflows/cd.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ on:
2121
jobs:
2222
ci:
2323
name: Build release
24-
runs-on: ubuntu-20.04
24+
runs-on: ubuntu-latest
2525
env:
2626
MIX_ENV: prod
2727
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28-
ELIXIR_VERSION: 1.13.4
29-
OTP_VERSION: 25.3.2
28+
ELIXIR_VERSION: 1.18.3
29+
OTP_VERSION: 27.3.3
3030
steps:
3131
- uses: rlespinasse/[email protected]
3232
- uses: actions/checkout@v4

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ on:
1919
jobs:
2020
ci:
2121
name: Run CI
22-
runs-on: ubuntu-20.04
22+
runs-on: ubuntu-latest
2323
env:
2424
MIX_ENV: test
2525
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

README.md

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ Cadet is the web application powering Source Academy.
2020

2121
It is probably okay to use a different version of PostgreSQL or Erlang/OTP, but using a different version of Elixir may result in differences in e.g. `mix format`.
2222

23+
> ## Setting up PostgreSQL
24+
>
25+
> The simplest way to get started is to use Docker. Simply [install Docker](https://docs.docker.com/get-docker/) and run the following command:
26+
>
27+
> ```bash
28+
> $ docker run --name sa-backend-db -e POSTGRES_HOST_AUTH_METHOD=trust -e -p 5432:5432 -d postgres
29+
> ```
30+
>
31+
> This configures PostgreSQL on port 5432. You can then connect to the database using `localhost:5432` as the host and `postgres` as the username. Note: `-e POSTGRES_HOST_AUTH_METHOD=trust` is used to disable password authentication for local development; since we are only accesing the database locally from our own machine, it is safe to do so.
32+
2333
### Setting up your local development environment
2434
2535
1. Set up the development secrets (replace the values appropriately)
@@ -29,8 +39,6 @@ It is probably okay to use a different version of PostgreSQL or Erlang/OTP, but
2939
$ vim config/dev.secrets.exs
3040
```
3141
32-
- To use NUSNET authentication, specify the NUS ADFS OAuth2 URL. (Ask for it.) Note that the frontend will supply the ADFS client ID and redirect URL (so you will need that too, but not here).
33-
3442
2. Install Elixir dependencies
3543

3644
```bash
@@ -49,19 +57,6 @@ It is probably okay to use a different version of PostgreSQL or Erlang/OTP, but
4957
$ mix ecto.setup
5058
```
5159

52-
If you encounter error message about invalid password for the user "postgres".
53-
You should reset the "postgres" password:
54-
55-
```bash
56-
$ sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
57-
```
58-
59-
and restart postgres service:
60-
61-
```bash
62-
$ sudo service postgresql restart
63-
```
64-
6560
By default, the database is populated with 10 students and 5 assessments. Each student will have a submission to the corresponding submission. This can be changed in `priv/repo/seeds.exs` with the variables `number_of_students`, `number_of_assessments` and `number_of_questions`. Save the changes and run:
6661

6762
```bash

config/config.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ config :cadet, Cadet.Jobs.Scheduler,
2525
# Compute rolling leaderboard every 2 hours
2626
{"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}},
2727
# Collate contest entries that close in the previous day at 00:01
28-
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}}
28+
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}},
29+
# Clean up expired exchange tokens at 00:01
30+
{"1 0 * * *", {Cadet.TokenExchange, :delete_expired, []}}
2931
]
3032

3133
# Configures the endpoint

config/dev.secrets.exs.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ config :cadet,
3636
# %{
3737
# assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor,
3838
# client_redirect_url: "http://cadet.frontend:8000/login/callback"
39+
# vscode_redirect_url_prefix: "vscode://source-academy.source-academy/sso",
40+
# client_post_exchange_redirect_url: "http://cadet.frontend:8000/login/vscode_callback",
3941
# }},
4042

4143
"test" =>

lib/cadet/code_exchange.ex

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
defmodule Cadet.TokenExchange do
2+
@moduledoc """
3+
The TokenExchange entity stores short-lived codes to be exchanged for long-lived auth tokens.
4+
"""
5+
use Cadet, :model
6+
7+
import Ecto.Query
8+
9+
alias Cadet.Repo
10+
alias Cadet.Accounts.User
11+
12+
@primary_key {:code, :string, []}
13+
schema "token_exchange" do
14+
field(:generated_at, :utc_datetime_usec)
15+
field(:expires_at, :utc_datetime_usec)
16+
17+
belongs_to(:user, User)
18+
19+
timestamps()
20+
end
21+
22+
@required_fields ~w(code generated_at expires_at user_id)a
23+
24+
def get_by_code(code) do
25+
case Repo.get_by(__MODULE__, code: code) do
26+
nil ->
27+
{:error, "Not found"}
28+
29+
struct ->
30+
if Timex.before?(struct.expires_at, Timex.now()) do
31+
{:error, "Expired"}
32+
else
33+
struct = Repo.preload(struct, :user)
34+
Repo.delete(struct)
35+
{:ok, struct}
36+
end
37+
end
38+
end
39+
40+
def delete_expired do
41+
now = Timex.now()
42+
43+
Repo.delete_all(from(c in __MODULE__, where: c.expires_at < ^now))
44+
end
45+
46+
def changeset(struct, attrs) do
47+
struct
48+
|> cast(attrs, @required_fields)
49+
|> validate_required(@required_fields)
50+
end
51+
52+
def insert(attrs) do
53+
changeset =
54+
%__MODULE__{}
55+
|> changeset(attrs)
56+
57+
changeset
58+
|> Repo.insert()
59+
end
60+
end

lib/cadet/courses/assessment_config.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ defmodule Cadet.Courses.AssessmentConfig do
1818
field(:early_submission_xp, :integer, default: 0)
1919
field(:hours_before_early_xp_decay, :integer, default: 0)
2020
field(:is_grading_auto_published, :boolean, default: false)
21+
# marks an assessment type as a minigame (with different submission and testcase behaviour)
22+
field(:is_minigame, :boolean, default: false)
2123

2224
belongs_to(:course, Course)
2325

@@ -26,7 +28,7 @@ defmodule Cadet.Courses.AssessmentConfig do
2628

2729
@required_fields ~w(course_id)a
2830
@optional_fields ~w(order type early_submission_xp
29-
hours_before_early_xp_decay show_grading_summary is_manually_graded has_voting_features has_token_counter is_grading_auto_published)a
31+
hours_before_early_xp_decay show_grading_summary is_manually_graded has_voting_features has_token_counter is_grading_auto_published is_minigame)a
3032

3133
def changeset(assessment_config, params) do
3234
assessment_config

lib/cadet_web/admin_views/admin_courses_view.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule CadetWeb.AdminCoursesView do
1010
assessmentConfigId: :id,
1111
type: :type,
1212
displayInDashboard: :show_grading_summary,
13+
isMinigame: :is_minigame,
1314
isManuallyGraded: :is_manually_graded,
1415
earlySubmissionXp: :early_submission_xp,
1516
hasVotingFeatures: :has_voting_features,

lib/cadet_web/controllers/auth_controller.ex

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ defmodule CadetWeb.AuthController do
55
use CadetWeb, :controller
66
use PhoenixSwagger
77

8-
alias Cadet.Accounts
9-
alias Cadet.Accounts.User
8+
alias Cadet.{Accounts, Accounts.User}
109
alias Cadet.Auth.{Guardian, Provider}
10+
alias Cadet.TokenExchange
1111

1212
@doc """
1313
Receives a /login request with valid attributes.
@@ -85,9 +85,87 @@ defmodule CadetWeb.AuthController do
8585
send_resp(conn, :bad_request, "Missing parameter")
8686
end
8787

88-
@spec create_user_and_tokens(Provider.authorise_params()) ::
89-
{:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
90-
defp create_user_and_tokens(
88+
@doc """
89+
Exchanges a short-lived code for access and refresh tokens.
90+
"""
91+
def exchange(
92+
conn,
93+
%{
94+
"code" => code,
95+
"provider" => provider
96+
}
97+
) do
98+
case TokenExchange.get_by_code(code) do
99+
{:error, _message} ->
100+
conn
101+
|> put_status(:forbidden)
102+
|> text("Invalid code")
103+
104+
{:ok, struct} ->
105+
tokens = generate_tokens(struct.user)
106+
107+
{_provider, %{client_post_exchange_redirect_url: client_post_exchange_redirect_url}} =
108+
Application.get_env(:cadet, :identity_providers, %{})[provider]
109+
110+
conn
111+
|> put_resp_header(
112+
"location",
113+
URI.encode(
114+
client_post_exchange_redirect_url <>
115+
"?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token
116+
)
117+
)
118+
|> send_resp(302, "")
119+
|> halt()
120+
end
121+
end
122+
123+
@doc """
124+
Alternate callback URL which redirect to VSCode via deeplinking.
125+
"""
126+
def saml_redirect_vscode(
127+
conn,
128+
%{
129+
"provider" => provider
130+
}
131+
) do
132+
code_ttl = 60
133+
134+
case create_user(%{
135+
conn: conn,
136+
provider_instance: provider,
137+
code: nil,
138+
client_id: nil,
139+
redirect_uri: nil
140+
}) do
141+
{:ok, user} ->
142+
code = generate_code()
143+
144+
TokenExchange.insert(%{
145+
code: code,
146+
generated_at: Timex.now(),
147+
expires_at: Timex.add(Timex.now(), Timex.Duration.from_seconds(code_ttl)),
148+
user_id: user.id
149+
})
150+
151+
{_provider, %{vscode_redirect_url_prefix: vscode_redirect_url_prefix}} =
152+
Application.get_env(:cadet, :identity_providers, %{})[provider]
153+
154+
conn
155+
|> put_resp_header(
156+
"location",
157+
vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code
158+
)
159+
|> send_resp(302, "")
160+
|> halt()
161+
162+
conn ->
163+
conn
164+
end
165+
end
166+
167+
@spec create_user(Provider.authorise_params()) :: {:ok, User.t()} | Plug.Conn.t()
168+
defp create_user(
91169
params = %{
92170
conn: conn,
93171
provider_instance: provider
@@ -96,7 +174,7 @@ defmodule CadetWeb.AuthController do
96174
with {:authorise, {:ok, %{token: token, username: username}}} <-
97175
{:authorise, Provider.authorise(params)},
98176
{:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do
99-
{:ok, generate_tokens(user)}
177+
{:ok, user}
100178
else
101179
{:authorise, {:error, :upstream, reason}} ->
102180
conn
@@ -121,6 +199,18 @@ defmodule CadetWeb.AuthController do
121199
end
122200
end
123201

202+
@spec create_user_and_tokens(Provider.authorise_params()) ::
203+
{:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
204+
defp create_user_and_tokens(params) do
205+
case create_user(params) do
206+
{:ok, user} ->
207+
{:ok, generate_tokens(user)}
208+
209+
conn ->
210+
conn
211+
end
212+
end
213+
124214
@doc """
125215
Receives a /refresh request with valid attribute.
126216
@@ -170,6 +260,14 @@ defmodule CadetWeb.AuthController do
170260
%{access_token: access_token, refresh_token: refresh_token}
171261
end
172262

263+
@spec generate_code :: String.t()
264+
defp generate_code do
265+
16
266+
|> :crypto.strong_rand_bytes()
267+
|> Base.url_encode64(padding: false)
268+
|> String.slice(0, 22)
269+
end
270+
173271
swagger_path :create do
174272
post("/auth/login")
175273

lib/cadet_web/controllers/chat_controller.ex

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule CadetWeb.ChatController do
66
use PhoenixSwagger
77

88
alias Cadet.Chatbot.{Conversation, LlmConversations}
9+
@max_content_size 1000
910

1011
def init_chat(conn, %{"section" => section, "initialContext" => initialContext}) do
1112
user = conn.assigns.current_user
@@ -21,7 +22,8 @@ defmodule CadetWeb.ChatController do
2122
"conversation_init.json",
2223
%{
2324
conversation_id: conversation.id,
24-
last_message: conversation.messages |> List.last()
25+
last_message: conversation.messages |> List.last(),
26+
max_content_size: @max_content_size
2527
}
2628
)
2729

@@ -51,13 +53,15 @@ defmodule CadetWeb.ChatController do
5153
response(200, "OK")
5254
response(400, "Missing or invalid parameter(s)")
5355
response(401, "Unauthorized")
56+
response(422, "Message exceeds the maximum allowed length")
5457
response(500, "When OpenAI API returns an error")
5558
end
5659

5760
def chat(conn, %{"conversationId" => conversation_id, "message" => user_message}) do
5861
user = conn.assigns.current_user
5962

60-
with {:ok, conversation} <-
63+
with true <- String.length(user_message) <= @max_content_size || {:error, :message_too_long},
64+
{:ok, conversation} <-
6165
LlmConversations.get_conversation_for_user(user.id, conversation_id),
6266
{:ok, updated_conversation} <-
6367
LlmConversations.add_message(conversation, "user", user_message),
@@ -85,6 +89,13 @@ defmodule CadetWeb.ChatController do
8589
send_resp(conn, 500, error_message)
8690
end
8791
else
92+
{:error, :message_too_long} ->
93+
send_resp(
94+
conn,
95+
:unprocessable_entity,
96+
"Message exceeds the maximum allowed length of #{@max_content_size}"
97+
)
98+
8899
{:error, {:not_found, error_message}} ->
89100
send_resp(conn, :not_found, error_message)
90101

@@ -107,4 +118,6 @@ defmodule CadetWeb.ChatController do
107118

108119
conversation.prepend_context ++ messages_payload
109120
end
121+
122+
def max_content_length, do: @max_content_size
110123
end

lib/cadet_web/router.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ defmodule CadetWeb.Router do
5151
post("/auth/login", AuthController, :create)
5252
post("/auth/logout", AuthController, :logout)
5353
get("/auth/saml_redirect", AuthController, :saml_redirect)
54+
get("/auth/saml_redirect_vscode", AuthController, :saml_redirect_vscode)
55+
get("/auth/exchange", AuthController, :exchange)
5456
end
5557

5658
scope "/v2", CadetWeb do

lib/cadet_web/views/assessments_view.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ defmodule CadetWeb.AssessmentsView do
5353
longSummary: :summary_long,
5454
hasTokenCounter: :has_token_counter,
5555
missionPDF: &Cadet.Assessments.Upload.url({&1.mission_pdf, &1}),
56+
isMinigame: & &1.config.is_minigame,
5657
questions:
5758
&Enum.map(&1.questions, fn question ->
5859
map =

0 commit comments

Comments
 (0)