Browse Source

push

master
nitowa 2 years ago
commit
0086c95dc0
21 changed files with 5836 additions and 0 deletions
  1. 7
    0
      .yarnrc.yml
  2. 16
    0
      Dockerfile
  3. 24
    0
      client/calculator.hbs
  4. 49
    0
      client/css/calc.css
  5. 21
    0
      client/css/common.css
  6. 9
    0
      client/css/main.css
  7. 28
    0
      client/index.html
  8. 106
    0
      client/js/ast-to-js.mjs
  9. 44
    0
      client/js/calc.mjs
  10. 64
    0
      client/js/eval-code.mjs
  11. 86
    0
      client/js/lex.mjs
  12. 98
    0
      client/js/main.mjs
  13. 234
    0
      client/js/parse.mjs
  14. 1996
    0
      package-lock.json
  15. 26
    0
      package.json
  16. 73
    0
      src/database.ts
  17. 97
    0
      src/index.ts
  18. 48
    0
      src/page-worker.ts
  19. 12
    0
      tsconfig.json
  20. 39
    0
      writeup.md
  21. 2759
    0
      yarn.lock

+ 7
- 0
.yarnrc.yml View File

@@ -0,0 +1,7 @@
1
+nodeLinker: node-modules
2
+
3
+plugins:
4
+  - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
5
+    spec: "@yarnpkg/plugin-typescript"
6
+
7
+yarnPath: .yarn/releases/yarn-berry.cjs

+ 16
- 0
Dockerfile View File

@@ -0,0 +1,16 @@
1
+FROM node:lts
2
+
3
+RUN apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
4
+
5
+WORKDIR /problem
6
+ADD .yarnrc.yml .
7
+ADD .yarn ./.yarn/
8
+ADD package.json .
9
+ADD yarn.lock .
10
+
11
+RUN yarn
12
+
13
+ADD . .
14
+RUN yarn build
15
+
16
+CMD ["yarn", "start"]

+ 24
- 0
client/calculator.hbs View File

@@ -0,0 +1,24 @@
1
+<html>
2
+    <head>
3
+        <link rel="stylesheet" href="/css/common.css">
4
+        <link rel="stylesheet" href="/css/calc.css">
5
+        <script id="program" language="json" type="{{ content-type }}">
6
+            {{ program }}
7
+        </script>
8
+
9
+        <script type="module">
10
+            window.addEventListener("load", () => {
11
+                import("/js/calc.mjs");
12
+            })
13
+        </script>
14
+    </head>
15
+    <body>
16
+        <div class="body">
17
+            <h1 class="title" id="name">Loading</h1>
18
+            <div id="input"></div>
19
+            <span id="output"></span>
20
+            <span id="error"></span>
21
+            <button id="report">Show me your math!</span>
22
+        </div>
23
+    </body>
24
+</html>

+ 49
- 0
client/css/calc.css View File

@@ -0,0 +1,49 @@
1
+.body #input {
2
+    display: flex;
3
+    flex-wrap: wrap;
4
+    justify-content: center;
5
+}
6
+
7
+.body #input > input {
8
+    justify-self: center;
9
+    outline: none;
10
+    padding: 16px;
11
+    margin: 16px;
12
+    font-size: 24px;
13
+    border: 2px solid #2a2a2a;
14
+    width: 128px;
15
+    height: 64px;
16
+    border-radius: 16px;
17
+    text-align: center;
18
+}
19
+
20
+.body #output {
21
+    font-size: 24px;
22
+    text-align: center;
23
+}
24
+
25
+.body #output:empty {
26
+    display: none;
27
+}
28
+
29
+.body #output::before {
30
+    content: "Result:";
31
+    color: #2a2a2a;
32
+    padding-right: 0.5em;
33
+}
34
+
35
+.body #error {
36
+    font-size: 24px;
37
+    text-align: center;
38
+    color: #e21c1c;
39
+}
40
+
41
+.body #error:empty {
42
+    display: none;
43
+}
44
+
45
+.body #error::before {
46
+    content: "Error:";
47
+    color: #e94e4e;
48
+    padding-right: 0.5em;
49
+}

+ 21
- 0
client/css/common.css View File

@@ -0,0 +1,21 @@
1
+body {
2
+    font-family: 'Oxygen';
3
+}
4
+
5
+.body {
6
+    display: flex;
7
+    max-width: 800px;
8
+    margin: auto;
9
+    flex-direction: column;
10
+}
11
+
12
+.body > * {
13
+    margin: 8px 0px;
14
+}
15
+
16
+.body .title {
17
+    justify-content: center;
18
+    display: flex;
19
+    flex-direction: row;
20
+}
21
+

+ 9
- 0
client/css/main.css View File

@@ -0,0 +1,9 @@
1
+.body #name {
2
+    max-width: 400px;
3
+    font-size: 24px;
4
+    align-self: center;
5
+}
6
+
7
+.body #program {
8
+    resize: vertical;
9
+}

+ 28
- 0
client/index.html View File

@@ -0,0 +1,28 @@
1
+<html>
2
+    <head>
3
+        <link rel="stylesheet" href="/css/common.css">
4
+        <link rel="stylesheet" href="/css/main.css">
5
+        <script type="module">
6
+            window.addEventListener("load", () => {
7
+                import("/js/main.mjs");
8
+            })
9
+        </script>
10
+    </head>
11
+    <body>
12
+        <div class="body">
13
+            <h1 class="title">Upload your Program to Run</h1>
14
+            <input id="name" placeholder="New Program"/>
15
+            <textarea id="program" style="min-height: 200px;">
16
+(-b + sqrt(b^2 - 4a*c)) / 2a
17
+            </textarea>
18
+            <div>
19
+                Choose a program type:
20
+                <select id="content-type">
21
+                    <option value="application/x-yaca-ast">AST</option>
22
+                    <option value="application/x-yaca-code" selected>Code</option>
23
+                </select>
24
+            </div>
25
+            <button id="upload">Upload</button>
26
+        </div>
27
+    </body>
28
+</html>

+ 106
- 0
client/js/ast-to-js.mjs View File

@@ -0,0 +1,106 @@
1
+const allowedMathFunctions = new Set([
2
+    "abs",
3
+    "acos",
4
+    "asin",
5
+    "atan",
6
+    "cos",
7
+    "sin",
8
+    "tan",
9
+    "ceil",
10
+    "floor",
11
+    "exp",
12
+    "log",
13
+    "log2",
14
+    "log10",
15
+    "sqrt",
16
+]);
17
+
18
+export default function astToJs(ast) {
19
+    if (typeof ast !== "object" || ast === null) {
20
+        throw new Error("Ast node must be an object");
21
+    }
22
+
23
+    switch (ast.kind) {
24
+        case "number": {
25
+            if (typeof ast.value !== "number") {
26
+                throw new Error("Number is of the wrong type");
27
+            }
28
+
29
+            return {
30
+                code: `${ast.value}`,
31
+                variables: new Set(),
32
+            };
33
+        }
34
+        case "variable": {
35
+            if (typeof ast.variable !== "string") {
36
+                throw new Error("Variable name not specified");
37
+            }
38
+
39
+            if (!ast.variable.match(/^[a-z][a-z0-9_]*$/)) {
40
+                throw new Error(`Invalid variable name: ${ast.variable}`);
41
+            }
42
+
43
+            const name = `var_${ast.variable}`;
44
+
45
+            return {
46
+                code: name,
47
+                variables: new Set([name]),
48
+            }
49
+        }
50
+        case "function": {
51
+            const { name, argument } = ast;
52
+            const { code: argumentCode, variables } = astToJs(argument);
53
+
54
+            if (typeof name !== "string") {
55
+                throw new Error("Function name must be a string");
56
+            }
57
+
58
+            if (!allowedMathFunctions.has(name)) {
59
+                throw new Error(`Invalid function: ${name}`);
60
+            }
61
+
62
+            const code = `Math.${name}(${argumentCode})`;
63
+
64
+            return { code, variables };
65
+        }
66
+        case "unop": {
67
+            const { code: nestedCode, variables } = astToJs(ast.value);
68
+            const op =
69
+                ast.op === "negate" ? "-" :
70
+                ast.op === "invert" ? "~" :
71
+                null;
72
+
73
+            if (op === null) {
74
+                throw new Error("Invalid unary operator");
75
+            }
76
+
77
+            const code = `${op}(${nestedCode})`;
78
+            return { code, variables };
79
+        }
80
+        case "binop":
81
+        {
82
+            const [left, right] = ast.values;
83
+            const leftResult = astToJs(left);
84
+            const rightResult = astToJs(right);
85
+            const op =
86
+                ast.op === "add" ? "+" :
87
+                ast.op === "subtract" ? "-" :
88
+                ast.op === "multiply" ? "*" :
89
+                ast.op === "divide" ? "/" :
90
+                ast.op === "exponent" ? "**" :
91
+                null; // null: never
92
+
93
+            if (op === null) {
94
+                throw new Error("Invalid binary operator");
95
+            }
96
+
97
+            return {
98
+                code: `(${leftResult.code} ${op} ${rightResult.code})`,
99
+                variables: new Set([...leftResult.variables, ...rightResult.variables]),
100
+            }
101
+        }
102
+        default: {
103
+            throw new Error(`Unknown ast kind: ${ast.kind}`);
104
+        }
105
+    }
106
+}

+ 44
- 0
client/js/calc.mjs View File

@@ -0,0 +1,44 @@
1
+import astToJs from "/js/ast-to-js.mjs";
2
+import evalCode from "/js/eval-code.mjs";
3
+import lex from "/js/lex.mjs";
4
+import parse from "/js/parse.mjs";
5
+
6
+const $ = document.querySelector.bind(document);
7
+const nameEl = $("#name");
8
+const errorEl = $("#error");
9
+const reportEl = $("#report");
10
+
11
+const astProgram = $("#program");
12
+const program = JSON.parse(astProgram.textContent);
13
+
14
+nameEl.innerText = `Running: ${program.name}`;
15
+
16
+reportEl.addEventListener("click", () => {
17
+    const file = location.pathname.split("/").slice(-1)[0];
18
+    reportEl.disabled = true;
19
+    reportEl.innerText = "Reported!";
20
+    fetch(`/report`, {
21
+        method: "POST",
22
+        headers: {
23
+            "Content-Type": "application/json"
24
+        },
25
+        body: JSON.stringify({ file })
26
+    });
27
+})
28
+
29
+try {
30
+    let ast;
31
+    if (astProgram.type === "application/x-yaca-code") {
32
+        const tokens = lex(program.code);
33
+        ast = parse(tokens);
34
+    } else {
35
+        ast = JSON.parse(program.code);
36
+    }
37
+
38
+    const jsProgram = astToJs(ast);
39
+    evalCode(jsProgram);
40
+} catch(e) {
41
+    console.error(e);
42
+    const msg = e instanceof Error ? e.message : "Something went wrong";
43
+    errorEl.innerText = msg;
44
+}

+ 64
- 0
client/js/eval-code.mjs View File

@@ -0,0 +1,64 @@
1
+const $ = document.querySelector.bind(document);
2
+
3
+const prepareInputs = (variables, onChange) => {
4
+    const inputEl = $("#input");
5
+
6
+    for (const node of [...inputEl.childNodes]) {
7
+        node.remove();
8
+    }
9
+
10
+    for (const variable of variables) {
11
+        const input = document.createElement("input");
12
+        input.setAttribute("type", "text");
13
+        input.setAttribute("placeholder", `${variable.slice(4)}`);
14
+        input.setAttribute("value", "");
15
+        input.addEventListener("keyup", () => {
16
+            let value = Number(input.value);
17
+            if (isNaN(value)) {
18
+                value = 0;
19
+            }
20
+
21
+            onChange(variable, value);
22
+        });
23
+        inputEl.appendChild(input);
24
+    }
25
+}
26
+
27
+const updateOutput = (result) => {
28
+    // Float truncation
29
+    if (typeof result === "number" && Math.floor(result) !== result) {
30
+        result = result.toFixed(2);
31
+    }
32
+
33
+    const $ = document.querySelector.bind(document);
34
+    const outputEl = $("#output");
35
+
36
+    outputEl.innerText = result;
37
+}
38
+
39
+
40
+export default ({ code, variables }) => {
41
+    const varList = [...variables].sort((a, b) => a.localeCompare(b));
42
+    const fn = new Function(...varList, `return (${code});`);
43
+    const values = new Map(varList.map((val) => [val, 0]));
44
+
45
+    const refresh = () => {
46
+        const result = fn(...varList.map((val) => values.get(val)));
47
+
48
+        if (isNaN(result)) {
49
+            throw new Error("Output was not a number")
50
+        }
51
+
52
+        $("#error").innerText = "";
53
+        updateOutput(result);
54
+    }
55
+
56
+    const onChange = (variable, value) => {
57
+        values.set(variable, value);
58
+        refresh();
59
+    }
60
+
61
+    prepareInputs(varList, onChange);
62
+
63
+    refresh();
64
+}

+ 86
- 0
client/js/lex.mjs View File

@@ -0,0 +1,86 @@
1
+export default (source) => {
2
+    let index = 0;
3
+    const tokens = [];
4
+
5
+    while (index < source.length) {
6
+        const token = source[index];
7
+        index += 1;
8
+
9
+        switch (token) {
10
+            case ' ':
11
+            case '\t':
12
+            case '\n':
13
+            case '\r':
14
+                break;
15
+            case "~":
16
+            case "+":
17
+            case "-":
18
+            case "*":
19
+            case "/":
20
+            case "^": {
21
+                const op =
22
+                    token === "~" ? "invert" :
23
+                    token === "+" ? "add" :
24
+                    token === "-" ? "subtract" :
25
+                    token === "*" ? "multiply" :
26
+                    token === "/" ? "divide" :
27
+                    token === "^" ? "exponent" :
28
+                    null;
29
+
30
+                tokens.push({
31
+                    kind: "operator",
32
+                    value: op,
33
+                });
34
+                break
35
+            }
36
+            case "(":  {
37
+                tokens.push({
38
+                    kind: "open-paren",
39
+                });
40
+                break;
41
+            }
42
+            case ")":  {
43
+                tokens.push({
44
+                    kind: "close-paren",
45
+                });
46
+                break;
47
+            }
48
+            default: {
49
+                if (token.match(/^[0-9\.]$/)) {
50
+                    let currentToken = token;
51
+
52
+                    while (index < source.length && source[index].match(/^[0-9\.]$/)) {
53
+                        currentToken += source[index];
54
+                        index += 1;
55
+                    }
56
+
57
+                    const value = Number(currentToken);
58
+                    tokens.push({
59
+                        kind: "number",
60
+                        value,
61
+                    });
62
+                    break;
63
+                }
64
+
65
+                if (token.match(/^[a-z]$/)) {
66
+                    let currentToken = token;
67
+
68
+                    while (index < source.length && source[index].match(/^[a-z0-9_]$/)) {
69
+                        currentToken += source[index];
70
+                        index += 1;
71
+                    }
72
+
73
+                    tokens.push({
74
+                        kind: "variable",
75
+                        value: currentToken,
76
+                    });
77
+                    break;
78
+                }
79
+
80
+                throw new Error(`Syntax error: Unexpected "${token}"`)
81
+            }
82
+        }
83
+    }
84
+
85
+    return tokens;
86
+}

+ 98
- 0
client/js/main.mjs View File

@@ -0,0 +1,98 @@
1
+const $ = document.querySelector.bind(document);
2
+const nameEl = $("#name");
3
+const uploadEl = $("#upload");
4
+const programEl = $("#program");
5
+const contentTypeEl = $("#content-type");
6
+
7
+uploadEl.addEventListener("click", async () => {
8
+    const code = programEl.value;
9
+    // We use logical or instead of nullish coalescing because
10
+    // [input] values are always coerced to a string
11
+    const name = nameEl.value || nameEl.getAttribute("placeholder");
12
+    const type = contentTypeEl.value;
13
+
14
+    const res = await fetch("/upload", {
15
+        method: "POST",
16
+        headers: {
17
+            "content-type": "application/json",
18
+        },
19
+        body: JSON.stringify({ type, program: { name, code } })
20
+    });
21
+
22
+    const url = await res.text();
23
+    window.location.href = url;
24
+});
25
+
26
+const staticAst = `{
27
+    "kind": "binop",
28
+    "op": "divide",
29
+    "values": [
30
+        {
31
+            "kind": "binop",
32
+            "op": "add",
33
+            "values": [
34
+                {
35
+                    "kind": "unop",
36
+                    "op": "negate",
37
+                    "value": { "kind": "variable", "variable": "b"}
38
+                },
39
+                {
40
+                    "kind": "function",
41
+                    "name": "sqrt",
42
+                    "argument": {
43
+                        "kind": "binop",
44
+                        "op": "subtract",
45
+                        "values": [
46
+                            {
47
+                                "kind": "binop",
48
+                                "op": "exponent",
49
+                                "values": [
50
+                                    { "kind": "variable", "variable": "b"},
51
+                                    { "kind": "number", "value": 2}
52
+                                ]
53
+                            },
54
+                            {
55
+                                "kind": "binop",
56
+                                "op": "multiply",
57
+                                "values": [
58
+                                    { "kind": "number", "value": 4 },
59
+                                    {
60
+                                        "kind": "binop",
61
+                                        "op": "multiply",
62
+                                        "values": [
63
+                                            { "kind": "variable", "variable": "a" },
64
+                                            { "kind": "variable", "variable": "c" }
65
+                                        ]
66
+                                    }
67
+                                ]
68
+                            }
69
+                        ]
70
+                    }
71
+                }
72
+            ]
73
+        },
74
+        {
75
+            "kind": "binop",
76
+            "op": "multiply",
77
+            "values": [
78
+                { "kind": "number", "value": 2 },
79
+                { "kind": "variable", "variable": "a" }
80
+            ]
81
+        }
82
+    ]
83
+}`;
84
+
85
+const staticCode = `(-b + sqrt(b^2 - 4a*c)) / 2a`;
86
+
87
+const resetTextArea = () => {
88
+    const contentType = contentTypeEl.value;
89
+
90
+    if (contentType === "application/x-yaca-ast") {
91
+        programEl.value = staticAst;
92
+    } else {
93
+        programEl.value = staticCode;
94
+    }
95
+}
96
+
97
+contentTypeEl.addEventListener("change", resetTextArea);
98
+resetTextArea();

+ 234
- 0
client/js/parse.mjs View File

@@ -0,0 +1,234 @@
1
+/*
2
+Operation precedences:
3
+   0: exponentiation
4
+   1: implicit multiplication
5
+   2: multiplication/division
6
+   3: unary operators
7
+   4: addition/subtraction
8
+*/
9
+
10
+
11
+// We could probably have lexed directly into the parse token, but
12
+// this way we keep a bit more separation of church & state
13
+const lexTokenToParseToken = (token) => {
14
+    if (token === undefined) {
15
+        return { kind: "EOF" };
16
+    }
17
+
18
+    switch (token.kind) {
19
+        case "operator": {
20
+            switch (token.value) {
21
+                case "invert":   return { kind: "UNOP", value: token.value, precedence: 3, isOp: true };
22
+                case "subtract": return { kind: "MAYBE_UNOP", value: token.value, unopValue: "negate", precedence: 4, isOp: true };
23
+                case "exponent": return { kind: "BINOP", value: token.value, precedence: 0, isOp: true, };
24
+                case "multiply": return { kind: "BINOP", value: token.value, precedence: 2, isOp: true, };
25
+                case "divide":   return { kind: "BINOP", value: token.value, precedence: 2, isOp: true, };
26
+                case "add":      return { kind: "BINOP", value: token.value, precedence: 4, isOp: true, };
27
+                default: throw new Error(`Unknown operator ${token.value}`);
28
+            }
29
+        }
30
+        case "open-paren": return { kind: "EXPR_START" };
31
+        case "close-paren": return { kind: "EXPR_END" };
32
+        case "number": return { kind: "VALUE", ast: { kind: "number", value: token.value } };
33
+        case "variable": return { kind: "VALUE", ast: { kind: "variable", variable: token.value } };
34
+        default: throw new Error(`Unknown token kind ${token.kind}`);
35
+    }
36
+}
37
+
38
+const mightBeUnop = (token) => {
39
+    return (
40
+        token.kind === "UNOP"
41
+        || token.kind === "MAYBE_UNOP"
42
+        || token.kind === "FUNCTION"
43
+        || token.kind === "IMPLICIT_MULTIPLICATION"
44
+    );
45
+}
46
+
47
+const mightBeBinop = (token) => {
48
+    return (
49
+        token.kind === "BINOP"
50
+        || token.kind === "MAYBE_UNOP"
51
+    );
52
+}
53
+
54
+const parseOne = (stack, lookahead) => {
55
+    // If we can reduce a parenthetical expression, we want to
56
+    if (stack[0]?.kind === "EXPR_END") {
57
+        const [_end, value, _start, ...rest] = stack;
58
+
59
+        if (stack[1]?.kind !== "VALUE" || stack[2]?.kind !== "EXPR_START") {
60
+            throw new Error("Received unexpected close parenthesis");
61
+        }
62
+
63
+        return ["reduce", [value, ...rest]];
64
+    }
65
+
66
+    // Otherwise, all of our reductions occur on values
67
+    if (stack[0]?.kind === "VALUE") {
68
+        // We have some special cases for two adjacent values, when the first one is
69
+        // a pure variable (function call) or number (term multiplication)
70
+        if (lookahead.kind === "VALUE" || lookahead.kind === "EXPR_START") {
71
+            if (stack[0].ast.kind === "variable") {
72
+                const [token, ...rest] = stack;
73
+                const newToken = {
74
+                    kind: "FUNCTION",
75
+                    isOp: true,
76
+                    precedence: 3,
77
+                    name: token.ast.variable
78
+                }
79
+
80
+                const newStack = [newToken, ...rest];
81
+                return ["reduce", newStack];
82
+            }
83
+
84
+            if (stack[0].ast.kind === "number") {
85
+                const [token, ...rest] = stack;
86
+                const newToken = {
87
+                    kind: "IMPLICIT_MULTIPLICATION",
88
+                    isOp: true,
89
+                    precedence: 1,
90
+                    value: token.ast.value
91
+                }
92
+
93
+                const newStack = [newToken, ...rest];
94
+                return ["reduce", newStack];
95
+            }
96
+        }
97
+
98
+        // If we have an operator on our stack, we want to reduce it unless the upcoming
99
+        // operator has a stronger precedence
100
+        if (stack[1]?.isOp) {
101
+            const shouldShift = mightBeBinop(lookahead) && lookahead.precedence < stack[1].precedence;
102
+            if (shouldShift) {
103
+                return ["shift"];
104
+            }
105
+
106
+            const isBinop = mightBeBinop(stack[1]) && stack[2]?.kind === "VALUE";
107
+
108
+            if (isBinop) {
109
+                const [right, binop, left, ...rest] = stack;
110
+
111
+                // Binops are easy
112
+                const newValue = {
113
+                    kind: "VALUE",
114
+                    ast: {
115
+                        kind: "binop",
116
+                        op: binop.value,
117
+                        values: [
118
+                            left.ast,
119
+                            right.ast,
120
+                        ]
121
+                    }
122
+                };
123
+
124
+                return ["reduce", [newValue, ...rest]]
125
+            }
126
+
127
+            const isUnop = mightBeUnop(stack[1]);
128
+            if (!isUnop) {
129
+                throw new Error("Unexpected operator");
130
+            }
131
+
132
+            const [value, unop, ...rest] = stack;
133
+            let newToken;
134
+
135
+            // Handle each of the different types of unary operators
136
+            switch (unop.kind) {
137
+                case "UNOP":
138
+                case "MAYBE_UNOP": {
139
+                    const op = unop.kind === "MAYBE_UNOP" ? unop.unopValue : unop.value;
140
+                    newToken = {
141
+                        kind: "VALUE",
142
+                        ast: {
143
+                            kind: "unop",
144
+                            op,
145
+                            value: value.ast
146
+                        }
147
+                    };
148
+                    break;
149
+                }
150
+                case "FUNCTION": {
151
+                    newToken = {
152
+                        kind: "VALUE",
153
+                        ast: {
154
+                            kind: "function",
155
+                            name: unop.name,
156
+                            argument: value.ast,
157
+                        }
158
+                    };
159
+                    break;
160
+                }
161
+                case "IMPLICIT_MULTIPLICATION": {
162
+                    newToken = {
163
+                        kind: "VALUE",
164
+                        ast: {
165
+                            kind: "binop",
166
+                            op: "multiply",
167
+                            values: [
168
+                                { kind: "number", value: unop.value },
169
+                                value.ast,
170
+                            ]
171
+                        }
172
+                    };
173
+                    break
174
+                }
175
+                default: {
176
+                    throw new Error(`Unknown unary operator: ${unop.kind}`);
177
+                }
178
+            }
179
+
180
+            return ["reduce", [newToken, ...rest]];
181
+        }
182
+    }
183
+
184
+    // Otherwise, we have no reductions to do, so we shift in a new token
185
+    return ["shift"];
186
+}
187
+
188
+
189
+export default (tokens) => {
190
+    const queue = [...tokens];
191
+    let stack = [];
192
+
193
+    const maxIter = 1000;
194
+    let iter = 0;
195
+
196
+    while (queue.length > 0 || stack.length > 1) {
197
+        // I haven't proven that this terminates so uh
198
+        // Hopefully this will keep me from nuking anyone's chrome
199
+        if (iter >= maxIter) {
200
+            throw new Error("Timeout");
201
+        }
202
+        iter++;
203
+
204
+        const lookahead = lexTokenToParseToken(queue[0]);
205
+        const action = parseOne(stack, lookahead);
206
+
207
+        if (window.DEBUG) {
208
+            console.log([...stack], lookahead, action);
209
+        }
210
+
211
+        switch (action[0]) {
212
+            case "shift": {
213
+                if (lookahead.kind === "EOF") {
214
+                    throw new Error("Attempting to shift EOF, which indicates a malformed program");
215
+                }
216
+
217
+                queue.shift();
218
+                stack = [lookahead, ...stack]
219
+                break;
220
+            }
221
+            case "reduce": {
222
+                stack = action[1];
223
+            }
224
+        }
225
+    }
226
+
227
+    // If we parsed correctly, we should be left with a single value
228
+    // representing our final result
229
+    if (stack[0]?.kind !== "VALUE") {
230
+        throw new Error("Parser did not return a value");
231
+    }
232
+
233
+    return stack[0].ast;
234
+}

+ 1996
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 26
- 0
package.json View File

@@ -0,0 +1,26 @@
1
+{
2
+  "name": "yaca",
3
+  "packageManager": "yarn@3.1.0",
4
+  "devDependencies": {
5
+    "@types/body-parser": "^1.19.2",
6
+    "@types/express": "^4.17.13",
7
+    "@types/fs-extra": "^9.0.13",
8
+    "@types/puppeteer": "^5.4.5",
9
+    "@types/sqlite3": "^3.1.8",
10
+    "@types/uuid": "^8",
11
+    "typescript": "^4.5.2"
12
+  },
13
+  "dependencies": {
14
+    "body-parser": "^1.19.0",
15
+    "express": "^4.17.1",
16
+    "fs-extra": "^10.0.0",
17
+    "puppeteer": "^13.5.2",
18
+    "sqlite3": "^5.0.2",
19
+    "uuid": "^8.3.2"
20
+  },
21
+  "scripts": {
22
+    "build": "tsc",
23
+    "start": "node dist/index.js",
24
+    "bundle": "bash -c 'tar -cvzf ../../bundle.tgz .yarn/{plugins,releases} .yarnrc.yml client src Dockerfile package.json tsconfig.json yarn.lock'"
25
+  }
26
+}

+ 73
- 0
src/database.ts View File

@@ -0,0 +1,73 @@
1
+import * as sqlite from "sqlite3";
2
+import * as path from "path";
3
+
4
+const db = new sqlite.Database(path.resolve("./queue.sqlite"));
5
+
6
+const run = <T>(sql: string, params: unknown[]) => {
7
+    return new Promise<T[]>((resolve, reject) => {
8
+        db.all(sql, ...params, (err: unknown, rows: T[]) => {
9
+            if (err) {
10
+                return reject(err);
11
+            }
12
+
13
+            resolve(rows);
14
+        });
15
+    });
16
+}
17
+
18
+export async function setup(){
19
+    await run(`
20
+    CREATE TABLE IF NOT EXISTS queue (
21
+        id SERIAL PRIMARY KEY,
22
+        url TEXT NOT NULL,
23
+        ip TEXT NOT NULL
24
+    );
25
+`, []);
26
+}
27
+
28
+export const enqueue = async (url: string, ip: string) => {
29
+    const [{ count }] = await run<{ count: number }>(`
30
+        SELECT count(*) as count
31
+        FROM queue
32
+        WHERE ip = ?;
33
+    `, [ip]);
34
+
35
+    if (count > 3) {
36
+        return false;
37
+    }
38
+
39
+    await run(`
40
+        INSERT INTO queue (url, ip)
41
+        VALUES (?, ?);
42
+    `, [url, ip]);
43
+
44
+    const [{ count: queueLength }] = await run<{ count: number }>(`
45
+        SELECT count(*) as count
46
+        FROM queue;
47
+    `, [])
48
+
49
+    return queueLength;
50
+}
51
+
52
+export const dequeue = async () => {
53
+    const request = await run<{ url: string, ip: string }>(`
54
+        SELECT url, ip
55
+        FROM queue
56
+        ORDER BY id ASC
57
+        LIMIT 1;
58
+    `, []);
59
+
60
+    if (request.length === 0) {
61
+        return undefined;
62
+    }
63
+
64
+    const [{ url, ip }] = request;
65
+
66
+    // Delete based off of url and ip in case there are duplicates
67
+    await run(`
68
+        DELETE FROM queue
69
+        WHERE url = ? AND ip = ?;
70
+    `, [url, ip]);
71
+
72
+    return url;
73
+}

+ 97
- 0
src/index.ts View File

@@ -0,0 +1,97 @@
1
+import * as express from "express";
2
+import * as fs from "fs-extra";
3
+import * as path from "path";
4
+import * as bodyParser from "body-parser";
5
+import { v4 as uuid } from "uuid";
6
+
7
+import { startVisiting } from "./page-worker";
8
+import { enqueue, setup } from "./database";
9
+
10
+const cacheDir = path.join(__dirname, "../cache");
11
+const clientDir = path.join(__dirname, "../client");
12
+
13
+const main = async () => {
14
+    await setup()
15
+    await fs.ensureDir(cacheDir);
16
+
17
+    const app = express();
18
+    app.use(bodyParser.json());
19
+    app.use((req, res, next) => {
20
+        res.setHeader("Content-Security-Policy", "script-src 'self' 'unsafe-eval' 'unsafe-inline'");
21
+        next();
22
+    });
23
+
24
+    app.get("/", (req, res) => {
25
+        res.sendFile(path.join(clientDir, "index.html"));
26
+    });
27
+
28
+    app.post("/upload", async (req, res) => {
29
+        if (typeof req.body !== "object") {
30
+            return res.status(500).send("Bad payload");
31
+        }
32
+
33
+        const { type, program } = req.body;
34
+        if (
35
+            typeof type !== "string"
36
+            || type.match(/^[a-zA-Z\-/]{3,}$/) === null
37
+            || typeof program.name !== "string"
38
+            || typeof program.code !== "string"
39
+            || program.code.length > 10000
40
+        ) {
41
+            return res.status(500).send("Invalid program");
42
+        }
43
+
44
+        const sanitizedProgram =
45
+            JSON.stringify(program)
46
+                .replace(/</g, "&lt;")
47
+                .replace(/>/g, "&gt;");
48
+
49
+        const template = await fs.readFile(path.join(clientDir, "calculator.hbs"), "utf-8");
50
+        const formattedFile =
51
+            template
52
+                .replace("{{ content-type }}", type)
53
+                .replace("{{ program }}", sanitizedProgram);
54
+
55
+        const fileName = `program-${uuid()}`;
56
+        await fs.writeFile(path.join(cacheDir, fileName), formattedFile);
57
+
58
+        res.send(`/program/${fileName}`);
59
+    });
60
+
61
+    app.post("/report", async (req, res) => {
62
+        if (
63
+            typeof req.body !== "object"
64
+            || typeof req.body.file !== "string"
65
+            || !req.body.file.match(/^program-[a-f0-9-]+$/)
66
+        ) {
67
+            return res.status(500).send("Bad payload");
68
+        }
69
+
70
+        const ip = req.ip;
71
+        const { file } = req.body;
72
+        const url = `http://localhost:3838/program/${file}`;
73
+
74
+        await enqueue(url, ip)
75
+        res.send("Ok");
76
+    })
77
+
78
+    app.get("/program/:file", async (req, res) => {
79
+        const fileName = req.params.file;
80
+        const filePath = path.join(cacheDir, fileName);
81
+
82
+        res.type("html");
83
+        res.sendFile(filePath);
84
+    });
85
+
86
+    app.use("/js", express.static(path.join(clientDir, "js")));
87
+    app.use("/css", express.static(path.join(clientDir, "css")));
88
+
89
+    app.listen(3838, () => {
90
+        console.log("Listening on port 3838");
91
+    });
92
+}
93
+
94
+startVisiting();
95
+main();
96
+
97
+

+ 48
- 0
src/page-worker.ts View File

@@ -0,0 +1,48 @@
1
+import * as puppeteer from "puppeteer";
2
+import * as fs from "fs/promises";
3
+import * as path from "path";
4
+
5
+import { dequeue } from "./database";
6
+
7
+const flag = process.env.FLAG ?? "flag{missing}"
8
+
9
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
10
+
11
+const visitOne = async () => {
12
+    const url = await dequeue();
13
+    if (url === undefined) {
14
+        await sleep(500);
15
+        return;
16
+    }
17
+
18
+    const browser = await puppeteer.launch({
19
+        dumpio: true,
20
+        pipe: true,
21
+        args: ['--no-sandbox', '--disable-setuid-sandbox']
22
+    });
23
+    const page = await browser.newPage();
24
+    await page.setCookie({
25
+        name: "flag",
26
+        value: flag,
27
+        domain: "localhost:3838",
28
+    })
29
+
30
+    await Promise.race([
31
+        page.goto(url),
32
+        sleep(3000),
33
+    ]);
34
+    await sleep(3000);
35
+
36
+    await browser.close();
37
+}
38
+
39
+export const startVisiting = async () => {
40
+    while (true) {
41
+        try {
42
+            await visitOne();
43
+        } catch (e) {
44
+            console.error(e);
45
+        }
46
+    }
47
+}
48
+

+ 12
- 0
tsconfig.json View File

@@ -0,0 +1,12 @@
1
+{
2
+    "compilerOptions": {
3
+        "strict": true,
4
+        "module": "CommonJS",
5
+        "target": "ES2020",
6
+        "declaration": true,
7
+        "incremental": true,
8
+        "sourceMap": true,
9
+        "outDir": "dist"
10
+    },
11
+    "include": ["src"]
12
+}

+ 39
- 0
writeup.md View File

@@ -0,0 +1,39 @@
1
+# Plaid CTF: Yet Another Calculator App
2
+
3
+Participant: Peter Millauer / nitowa (01350868)
4
+
5
+## TL;DR / Short Summary
6
+
7
+Classical XSS web exploit. The solution used special string replacement patterns to break out of string escapes.
8
+
9
+## Task Description
10
+
11
+
12
+
13
+## Analysis Steps
14
+
15
+Explain your analysis in detail. Cover all the technical aspects, including the used tools and commands. Mention other collaborators and distinguish contributions.
16
+
17
+## Vulnerabilities / Exploitable Issue(s)
18
+
19
+List security issues you discovered in the scope of the task and how they could be exploited.
20
+
21
+## Solution
22
+
23
+Provide a clean (i.e., without analysis and research steps) guideline to get from the task description to the solution. If you did not finish the task, take your most promising approach as a goal.
24
+
25
+## Failed Attempts
26
+
27
+Describe attempts apart from the solution above which you tried. Recap and try to explain why they did not work.
28
+
29
+## Alternative Solutions
30
+
31
+If you can think of an alternative solution (or there are others already published), compare your attempts with those.
32
+
33
+## Lessons Learned
34
+
35
+Document what you learned during the competition.
36
+
37
+## References
38
+
39
+List external resources (academic papers, technical blogs, CTF writeups, ...) you used while working on this task.

+ 2759
- 0
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save