Toying around with API ideas

This commit is contained in:
Neale Pickett 2024-04-11 16:44:01 -06:00
parent 7c5b5b5ccf
commit ceb0cb0edb
12 changed files with 513 additions and 436 deletions

10
.gitignore vendored
View File

@ -1,10 +1,10 @@
*~ *~
*# *#
.idea /.idea
/vendor/ /vendor/
__debug_bin /__debug_bin
*.tar.gz /winmoth.*.zip
transpile /*.tar.gz
winmoth.*.zip /transpile
/mothd /mothd
/*.exe /*.exe

163
docs/api-client.md Normal file
View File

@ -0,0 +1,163 @@
MOTH Client API
===========
MOTH provides a WebDAV interface:
this is described in
[MOTH Client Directory Structure](client-structure.md).
This document explains the WebDAV directory structure
as though it were a REST API.
These endpoints are a subset of the functionality provided,
but should be sufficient for many use cases.
Theme
======
Theme files are served as static content,
just like any standard web server.
### `GET` `/theme/${path}` - Retrieve File
Puzzles
======
Static Files
----------
With the exception of the `answer` file,
puzzle files are served as static content.
The entry point to a puzzle is `index.html`:
see [Puzzle Format](puzzle-format.md)
for details on its structure.
### `GET` `/puzzles/${category}/${points}/${filename}` - Retrieve File
Answer Submission
------------------
### `GET` `/puzzles/${category}/${points}/answer` - not supported
#### Responses
| http code | meaning |
| ---- | ---- |
| 405 | `GET` method is not supported |
### `POST` `/puzzles/${category}/${points}/answer` - Submit Answer
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Answer is correct, and points are awarded |
| 202 | Answer is correct, but points have already been awarded for this puzzle |
| 409 | Answer is incorrect |
| 401 | Authentication is invalid (bad team ID) |
State
====
Points Log
--------
The points log contains a history of correct answer submission.
Each submission is terminated by a newline (`\n`)
and consists of space-separated fields
of the format:
${timestamp} ${team_id} ${category} ${points}
### `GET` `/state/points.log` - Retrieve points log
| http code | meaning |
| ---- | ---- |
| 200 | Points log in payload (text/plain) |
| 401 | Authentication is invalid (bad team ID) |
Team Name
--------
### `GET` `/state/self/name` - Retrieve my team name
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Team ID in payload (text/plain) |
| 401 | Authentication is invalid (bad team ID) |
### `POST` `/state/self/name` - Set my team name
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Team ID is valid, and team name was recorded |
| 202 | Team ID is valid, but team name was previously set and cannot be changed |
| 401 | Authentication is invalid (bad team ID) |
Public Data
--------
Up to 4096 bytes of arbitrary public data per team may be stored on the server.
This data can be viewed by any authenticated team.
There are no restrictions on the content of the data:
clients are free to store whatever they want.
### `GET` `/state/${id}/public.bin` - Retrieve public data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |
### `PUT` `/state/${id}/public.bin` - Upload public data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |
Private Data
--------
Up to 4096 bytes of arbitrary data per team may be stored on the server.
This data is only accessible by an authenticated request,
and is private to the authenticated team.
There are no restrictions on the content of the data:
clients are free to store whatever they want.
### `GET` `/state/self/private.bin` - Retrieve private data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |
### `POST` `/state/self/private.bin` - Upload private data
#### Responses
| http code | meaning |
| ---- | ---- |
| 200 | Data follows (application/octet-stream) |
| 401 | Authentication is invalid (bad team ID) |

View File

@ -1,430 +0,0 @@
Moth APIs
=======
This document covers the following interfaces:
* HTTP Endpoints: what the Moth client sends the Moth server
* Puzzle executable: how the transpiler communicates with executables that provide puzzles
* Category executable: how the transpiler communicates with executables that provide categories
* Provider executable: how Moth communicates with things that provide puzzles (like the transpiler)
The Puzzle, Category, and Provider executalbes are all very closely related, since each is a subset of the next.
----
Here's a bad diagram of how this all fits together. I don't know if this is going to help at all. Please submit a merge request with something better.
HTTP provider API mothball API
🡗 🡗 🡗
client - mothd - mothball provider - category1.mb
- custom provider
category API
🡗
- internal transpiler - category2/mkcategory
- category3/1/puzzle.md
- category3/2/mkpuzzle
🡔
puzzle API
# HTTP Endpoints
The Moth server accepts
standard HTTP `GET` and `POST`.
Parameters may be encoded with standard `GET` query parameters
(like `GET /endpoint?a=1&b=2`),
or with `POST` as `application/x-www-form-encoded` data.
## `/state`
Returns the current Moth event state as a JSON object.
### Parameters
* `id`: team ID (optional)
### Return
```js
{
"Config": {
"Devel": false // true means this is a development server
},
"TeamNames": {
"self": "Requesting team name", // Only if regestered team id is a provided
"0": "Team 1 Name",
"1": "Team 2 Name"
// ...
},
"PointsLog": [
[1602679698, "0", "category", 1] // epochTime, teamID, category, points
// ...
],
"Puzzles": {
"category": [1, 2, 3, 6] // list of unlocked puzzles for category
// ...
}
}
```
### Example HTTP transaction
#### Request
```
GET /state HTTP/1.0
```
#### Response
This response has been reflowed for readability:
an actual on-wire response would not have newlines or indentation.
```
HTTP/1.0 200 OK
Content-Type: application/json
{"Config":
{"Devel":false},
"TeamNames":{
"0":"Mike and Jack",
"12":"Team 2",
"4":"Team 8"
},
"PointsLog":[
[1602702696,"0","nocode",1],
[1602702705,"0","sequence",1],
[1602702787,"0","nocode",2],
[1602702831,"0","sequence",2],
[1602702839,"4","nocode",3],
[1602702896,"0","sequence",8],
[1602702900,"4","nocode",4],
[1602702913,"0","sequence",16]
],
"Puzzles":{
"indy":[12],
"nocode":[1,2,3,4,10],
"sequence":[1,2,8,16,19],
"steg":[1]
}
}
```
## `/register`
Registers a name to a team ID.
This is only required once per team,
but user interfaces may find it less confusing to users
to present a "login" page.
For this reason "this team is already registered"
does not return an error.
### Parameters
* `id`: team ID
* `name`: team name
### Return
An object inspired by [JSend](https://github.com/omniti-labs/jsend):
```json
{
"status": "success/fail/error",
"data": {
"short": "short description",
"description": "long description"
}
}
```
### Example HTTP transaction
#### Request
```
POST /register HTTP/1.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 26
id=b387ca98&name=dirtbags
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length=86
{"status":"success","data":{"short":"registered","description":"Team ID registered"}}
```
## `/answer`
Submits an answer for points.
If the answer is wrong, no points are awarded 😉
### Parameters
* `id`: team ID
* `category`: along with `points`, uniquely identifies a puzzle
* `points`: along with `category`, uniquely identifies a puzzle
### Return
An object inspired by [JSend](https://github.com/omniti-labs/jsend):
```json
{
"status": "success/fail/error",
"data": {
"short": "short description",
"description": "long description"
}
}
```
### Example HTTP transaction
#### Request
```
POST /answer HTTP/1.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 62
id=b387ca98&category=sequence&points=2&answer=achilles+turnip
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length=83
{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}
```
## `/content/{category}/{points}/puzzle.json`
Retrieves the JSON object describing a puzzle.
Parameters are all in the URL for this endpoint,
so `curl` and `wget` can be used.
### Parameters
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{filename}` (in URL): filename to retrieve
### Return
JSON object describing a puzzle.
#### JSON Puzzle Object
```js
{
"Pre": { // Things which appear before the puzzle is solved
"Authors": ["Neale Pickett"], // List of puzzle authors, usually rendered as a footnote
"Attachments": ["tiger.jpg"], // List of files attached to the puzzle
"Scripts": [], // List of scripts which should be included in the HTML render of the puzzle
"Body": "<p>Can you find the hidden text?</p><p><img src=\"tiger.jpg\" alt=\"Grr\" /></p>\n", // HTML puzzle body
"AnswerPattern": "", // Regular expression to include in HTML input tag for validation
"AnswerHashes": [ // List of SHA265 hashes of correct answers, for client-side answer checking
"f91b1fe875cdf9e969e5bccd3e259adec5a987dcafcbc9ca8da62e341a7f29c6"
]
},
"Post": { // Things reveal after the puzzle is solved
"Objective": "Learn to examine images for hidden text", // Learning objective
"Success": { // Measures of learning success
"Acceptable": "Visually examine image to find hidden text",
"Mastery": "Visually examine image to find hidden text"
},
"KSAs": null // Knowledge, Skills, and Abilities covered by this puzzle
},
"Debug": { // Debugging output used in development: all fields are emptied when making mothballs
"Log": [ // Debug message log
"Input image size: 600x400",
"Applying gaussian blur",
"Text width 58, left offset 513",
"Complete in 0.028s"
],
"Errors": [], // Errors encountered generating this puzzzle
"Hints": [ // Hints for instructional assistants to provide to participants
"Zoom in to the image and examine all sections carefully"
],
"Summary": "text in image" // Summary of this puzzle, to help identify it in an overview of puzzles
},
"Answers": ["sandwich"] // List of answers: empty in production
}
```
### Example HTTP transaction
#### Request
```
GET /content/sequence/1/puzzle.json HTTP/1.0
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 397
{"Pre":{"Authors":["neale"],"Attachments":[],"Scripts":[],"Body":"\u003cp\u003e1 2 3 4 5 ⬜\u003c/p\u003e\n","AnswerPattern":"","AnswerHashes":["e7f6c011776e8db7cd330b54174fd76f7d0216b612387a5ffcfb81e6f0919683"]},"Post":{"Objective":"","Success":{"Acceptable":"","Mastery":""},"KSAs":null},"Debug":{"Log":[],"Errors":[],"Hints":[],"Summary":"Simple introduction to how this works"},"Answers":[]}
```
## `/content/{category}/{points}/{filename}`
Retrieves static content associated with a puzzle.
Parameters are all in the URL for this endpoint,
so `curl` and `wget` can be used.
### Parameters
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
* `{filename}` (in URL): filename to retrieve
### Return
Raw file octets,
with a (hopefully) suitable
`Content-type` HTTP header field.
### Example HTTP transaction
#### Request
```
GET /content/sequence/1/attachment.txt HTTP/1.0
```
#### Repsonse
```
HTTP/1.0 200 OK
Content-Type: text/plain
Content-Length: 98
This is an attachment file! This is just plain text for the example. Many attachments are JPEGs.
```
# Puzzle
A puzzle contains one question and one or more associated answers.
Puzzles are not aware of their point value: this is set by the category they are in.
Puzzle executables must be named `mkpuzzle`.
## `mkpuzzle puzzle`
puzzles/category3/1 $ ./mkpuzzle puzzle
{JSON PUZZLE OBJECT}
Also see [JSON Puzzle Object](#json-puzzle-object)
## `mkpuzzle file {filename}`
puzzles/category3/1 $ ./mkpuzzle file attachment.txt
This is an attachment file! It's just plain text for this example. Many attachments are JPEGs.
## `mkpuzzle answer {answer}`
puzzles/category3/1 $ ./mkpuzzle answer "cow goes moo"
{"Correct":false}
# Category
Categories are collections of puzzles.
Each puzzle has a unique point value, determined by the category.
Category executables must be called `mkcategory`.
## `mkcategory inventory`
puzzles/category2 $ ./mkcategory inventory
{"Puzzles": [1, 2, 3, 5, 10, 20, 30, 50, 100]}
## `mkcategory puzzle {points}`
puzzles/category2 $ ./mkcategory puzzle 1
{JSON PUZZLE OBJECT}
Also see [JSON Puzzle Object](#json-puzzle-object)
## `mkcategory file {points} {filename}`
puzzles/category2 $ ./mkcategory file 1 attachment.txt
This is an attachment file's contents!
## `mkcategory answer {points} {answer}`
puzzles/category2 $ ./mkcategory answer 1 "cow goes moo"
{"Correct":false}
# Provider API
This is how Claire gets her dynamic graders.
*Notice: this is not complete in the code base!*
I'm writing here how it *should* work.
If anybody wants this,
please let me know,
and I'll finish the code.
This could ostensibly be expanded to call HTTP servers,
with the four endpoints described here.
If somebody were to want such a thing.
## `provider inventory`
$ provider inventory
{
"category1": [1, 2, 3, 4, 5, 10, 20, 30],
"category2": [20, 40, 70, 150]
}
## `provider puzzle {category} {points}`
$ provider puzzle category1 20
{JSON PUZZLE OBJECT}
Also see [JSON Puzzle Object](#json-puzzle-object)
## `provider file {category} {points} {filename}`
$ provider file category1 20 attachment.txt
This is an attachment! Yay!
## `provider answer {category} {points} {answer}`
$ provider answer category1 20 "cow goes moo"
{"Correct":true}

55
docs/client-structure.md Normal file
View File

@ -0,0 +1,55 @@
MOTH Client Directory Structure
=======
MOTHv5 implements WebDAV.
Depending on the authentication level of a user,
files may be read-only, or read-write.
WebDAV allows you to mount MOTH as a local filesystem.
You are encouraged to do this,
and use this document as a reference.
Directory Structure: Participant
-----------
Here is an example list of the files available to participants.
r- /state/points.log
rw /state/self/name
rw /state/self/private.dat
rw /state/self/public.dat
r- /state/1/name
r- /state/1/public.dat
r- /state/2/name
r- /state/2/public.dat
r- /puzzles/category-a/1/index.html
rw /puzzles/category-a/1/answer
r- /puzzles/category-a/2/index.html
rw /puzzles/category-a/2/answer
r- /puzzles/category-a/2/attachment.jpg
r- /puzzles/category-b/1/index.html
rw /puzzles/category-b/1/answer
r- /theme/*
Directory Structure: Anonymous
-----------
Anonymous (unauthenticated) users
have a restricted view:
r- /state/points.log
rw /state/self/name
rw /state/self/private.dat
rw /state/self/public.dat
r- /state/1/name
r- /state/1/public.dat
r- /state/2/name
r- /state/2/public.dat
Directory Structure: Administrator
------------
Here is an example list of the files available
to an administrator.

50
docs/metadata.md Normal file
View File

@ -0,0 +1,50 @@
MOTH Metadata
============
Standard Metadata
-----------------
The following are considered "standard" MOTH metadata.
Clients *should* check for,
and take appropriate action on,
all of these metadata names.
| name | description | permitted values | example |
| --- | --- | --- | --- |
| author | Puzzle author(s). | free text [(ref)](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name) | `Neale Pickett` |
| moth.style | Whether the client should inject a style sheet. Default: `inherit` | `override`, `inherit` | `override` |
| moth.answerhash | Answer hash, used for "possibly correct" check in client. | MOTHv5: first 8 characters of answer's SHA1 checksum | `a5b6bb92` |
| moth.answerpattern | Answer pattern, to use as `pattern` attribute of `<input>` element for answer. | Regular Expression [(ref)](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern) | `\w{3,16}`
| moth.ksa | [NICE KSA](https://niccs.cisa.gov/workforce-development/nice-framework) achieved by completing this puzzle. | NICE KSA identifier | `K0052` |
| moth.objective | Learning objective of this puzzle. | free text | `Count in octal` |
| moth.success.acceptable | The minimum work required to be considered successfully understanding this puzzle's concepts | free text | `Recognize pattern` |
| moth.success.mastery | The work required to be considered mastering this puzzle's concepts | free text | `Understand 8s place in octal` |
Standard Debugging Metadata
----------------
These metadata names are for debugging purposes.
The *must not* be present in a production instance.
| name | description | permitted values | example |
| --- | --- | --- | --- |
| moth.debug.answer | An accepted answer | free text | `pink hat horse race` |
| moth.debug.summary | A summary of the puzzle, to help staff remember what it is | free text | `Hidden white text in the rendered image` |
| moth.debug.hint | A hint that staff can provide to participants | free text | `This puzzle can be solved by a grade school student with no special tools` |
| moth.debug.notes | Notes to staff intended to help better understand the puzzle | free text | `We used this image because Scott likes tigers` |
| moth.debug.log | A log message | free text | `iterations: 5` |
| moth.debug.errors | Error messages | free text | `unable to open foo.bin` |
Client Metadata
-----------
Clients wishing to implement additional metadata
*should* either submit a merge request to this document,
or use a `moth/$client.` prefix.
For example, the "tofu" client might use a
`moth/tofu.difficulty` name.

183
docs/puzzle-format.md Normal file
View File

@ -0,0 +1,183 @@
MOTH Puzzle Format
===========
MOTH puzzles are HTML5 documents,
with optional metadata.
Puzzles may contain stylesheets and scripts,
or any other feature made available by HTML5.
Typically, a puzzle will be rendered in an `<object>` tag
in the MOTH client.
Some clients may copy over scripts, stylesheets,
and embed the puzzle's `<body>` in the page.
Within a puzzle directory,
the puzzle itself is named `index.html`.
MOTH Metadata
=============
Puzzles may contain metadata,
which can be used by MOTH clients to alter display of puzzles,
or provide additional information in the UI.
Metadata is provided in HTML `<meta>` elements,
with the `name` attribute specifying the metadata name,
and the `content` attribute specifying the metadata content.
Multiple elements with the same `name` are generally permitted.
Metadata names are defined in detail in
[MOTH Metadata](metadata.md).
For example, the following `<meta>` elements
could appear in the `<head>` section of a puzzle's HTML:
```html
<meta name="author" content="Neale Pickett">
<meta name="moth.answerhash" content="87bcc390">
<meta name="moth.answerhash" content="622fcbe8">
<meta name="moth.objective" content="Understand radix 8 (octal)">
```
Images, Attachments, Scripts, and Style Sheets
===================
Related files can be referenced directly in HTML.
Related files *should* be located in the same directory as `index.html`,
but situations may exist where it makes more sense
to locate a file in the parent directory.
Related files are not hidden:
they can be discovered with an http `PROPFIND` method.
For example, assuming `honey.jpg` exists in the same directory
as `index.html`, a standard `<img>` tag will work:
```html
<img src="honey.jpg"
alt="A clay jar with the word 'honey' printed on the front."
title="Honey jar">
```
Puzzle Events
==============
As HTML5 documents,
MOTH puzzles can communicate with the MOTH client
using HTML5 events.
setAnswer
--------
A MOTH Puzzle may advise the client to fill the answer field with text
by emitting an `setAnswer` custom event.
For example, the following code will advice the client to set the answer field to the string `bloop`:
```javascript
let answerEvent = new CustomEvent(
"setAnswer",
{
detail: {value: 'bloop'},
bubbles: true,
cancelable: true
},
)
document.dispatchEvent(answerEvent)
```
MOTH clients *should* listen for such events,
and fill the answer input field with the event's value.
Puzzles *must* provide the user with a copy/paste-able representation of the answer, in the event the event is not handled correctly by the client.
Example Puzzles
=========
Minimally Valid Puzzle
---------
This puzzle provides the absolute minimum required:
a title, and puzzle contents.
```html
<!DOCTYPE html>
<title>Counting</title>
<p>1 2 3 4 5 _</p>
```
Puzzle with metadata
-----------------
Typically, puzzles will provide metadata,
to enable client features such as "possibly correct" validation,
author display, learning objectives
```html
<!DOCTYPE html>
<html>
<head>
<title>Counting Sheep</title>
<meta name="author" content="Neale Pickett">
<meta name="moth.answerhash" content="089c7244">
<meta name="moth.answerhash" content="92837b4f">
<meta name="moth.objective" content="Recognize the difference between a sheep and a wolf">
<meta name="moth.objective" content="Count to a high number">
<meta name="moth.success.acceptable" content="Count using fingers">
<meta name="moth.success.mastery" content="Count using software tools, and provide answer in hexadecimal">
</head>
<body>
<p>🐑🐑🐑🐑🐑🐑🐑🐑🐺🐑🐑</p>
<p>How many sheep?</p>
</body>
</html>
```
Puzzle with images, scripts, and style
---------------------------
Since they are rendered as HTML documents,
puzzles may include any HTML5 feature.
```html
<!DOCTYPE html>
<html>
<head>
<title>Basic Sight Reading</title>
<meta name="author" content="Neale Pickett">
<meta name="moth.answerhash" content="baabaa08">
<meta name="moth.objective" content="Play a tune provided in sheet music">
<meta name="moth.success.acceptable" content="Play the requested tune with no mistakes">
<link rel="stylesheet" href="style.css">
<script src="midi-transcriber.mjs" type="module"></script>
</head>
<body>
<p>
Using the provided sheet music,
play "May Had A Little Lamb" on your MIDI keyboard.
If you make a mistake,
press the "reset" button and start over.
</p>
<p>
Once you have played from start to finish with no mistakes,
paste the computed answer into the answer box.
</p>
<img src="mary-lamb.png"
alt="Sheet music: |EDCD|EEE.|DDD.|EEE.|"
title="Sheet music for 'Mary Had A Little Lamb">
<label for="notes">Notes Played</label>
<output id="notes"></output>
<button id="reset">Reset</button>
<label for="answer">Answer</label>
<output id="answer"></output>
</body>
</html>
```

View File

@ -78,7 +78,7 @@ type Puzzle struct {
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts // Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
Acceptable string Acceptable string
// Mastery describes the work required to be considered mastering this puzzle's conceptss // Mastery describes the work required to be considered mastering this puzzle's concepts
Mastery string Mastery string
} }
} }

4
theme/test/index.css Normal file
View File

@ -0,0 +1,4 @@
html {
background: #333;
color: white;
}

10
theme/test/index.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Puzzle Viewer</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<object type="text/html" data="puzzle.html"></object>
</body>
</html>

42
theme/test/puzzle.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<title>Example MOTHv5 Puzzle</title>
<!-- style tells the client whether to inject its own stylesheet.
By default, a client will try to style a puzzle using its own stylesheet.
If you want to override this behavior, provide "style" with
content="override".
Omitting this meta element is the same as content="inherit".
-->
<meta name="moth.style" content="inherit">
<!-- moth.answerhash is used for the "possibly correct" check.
This is the first 8 characters of the hex-encoded sha1 checksum of the answer.
If you have multiple acceptable answers, provide multiple answerhash elements.
-->
<meta name="moth.answerhash" content="2c26b46b">
<!-- author specifies the author of this puzzle.
If you have multiple authors, specify multiple meta elements,
one author per element.
-->
<meta name="author" content="Neale Pickett">
<meta name="author" content="Ford Powers">
<script href="example.mjs" type="module"></script>
</head>
<body>
<h1>Example Puzzle</h1>
<p>
This is an example puzzle, yo.
You can put whatever you want in the HTML.
If you do crazy tricks, it might break the client, though.
</p>
<img src="salad.jpg">
</body>
</html>

BIN
theme/test/salad.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
theme/test/salad2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB