Merge pull request #4 from nbitzz/1.3.0

1.3.0
This commit is contained in:
split / May 2023-08-27 01:19:26 -07:00 committed by GitHub
commit 9089f876a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
112 changed files with 8208 additions and 3121 deletions

6
.gitignore vendored
View file

@ -1,4 +1,4 @@
node_modules
.env
.data
node_modules
.env
.data
out

44
.vscode/tasks.json vendored
View file

@ -1,23 +1,23 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"command":"npx tsc",
"group": {
"kind": "build",
"isDefault": true
},
"label": "Build (Bot Server)"
},
{
"type": "shell",
"command":"npx tsc\nnode ./out/index.js\ndel ./out/* -Recurse",
"group": {
"kind": "build",
"isDefault": true
},
"label": "Build & Test"
}
]
{
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"command":"tsc\nsass src/style:out/style\nrollup -c",
"group": {
"kind": "build",
"isDefault": true
},
"label": "Build (Bot Server)"
},
{
"type": "shell",
"command":"tsc\nsass src/style:out/style\nrollup -c\nnode ./out/server/index.js\ndel ./out/* -Recurse",
"group": {
"kind": "build",
"isDefault": true
},
"label": "Build & Test"
}
]
}

46
LICENSE
View file

@ -1,24 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

107
README.md
View file

@ -1,32 +1,75 @@
# monofile
File sharing via Discord
<br>
## .env
```
TOKEN=KILL-YOURSELF.NOW
```
## versions & planned updates
- [X] 1.0.0 initial release
- [X] 1.1.0 add file cloning endpoint
- [X] 1.1.1 add file cloning webpage
- [X] 1.1.2 fix file cloning with binary data
- [X] 1.1.3 display current version on pages
- [X] 1.1.4 serve /assets as static files & make /server endpoint
- [X] 1.2.0 add file parameters section + custom ids
- [X] 1.2.1 add file counter to main page
- [X] 1.2.2 clean up this shitty code
- [X] 1.2.3 bugfixes
- [ ] 1.2.4 add id locks, allowing you to set a key for a file that allows you to overwrite the file in the future
- [ ] 1.2.5 prevent cloning of local/private ip addresses
- [ ] 1.3.0 add simple moderation tools
- [ ] 2.0.0 rewrite using theUnfunny's code as a base/rewrite using monofile-core
also todo: monofile-core (written in eris)
## Disclaimer!
This project does some stuff that can be considered questionable. Discord may not like you uploading files this way, and it's a grey area in Discord's TOS. We take no responsibility if Discord locks your account for API abuse.
# monofile
The open-source, Discord-based file sharing service.
[Live instance](https://fyle.uk)
<br>
## Setup
First, install monofile's prerequisites...
```
npm i
```
Then, add your bot token...
```
echo "TOKEN=INSERT-TOKEN.HERE" > .env
```
and, in addition, SMTP authentication...
```
echo "\nMAIL_USER=user@example.com" > .env
echo "\nMAIL_PASS=password here" > .env
```
Invite your bot to a server, and create a new `config.json` in the project root:
```js
// config.json
{
"maxDiscordFiles": 20,
"maxDiscordFileSize": 26214400,
"targetGuild": "1024080490677936248",
"targetChannel": "1024080525993971913",
"requestTimeout":120000,
"maxUploadIdLength":30,
"accounts": {
"registrationEnabled": true,
"requiredForUpload": false
},
"webdrop": {
"accountRequired": false
},
"mail": { // nodemailer transport options
"host": "smtp.fastmail.com", // or your mail provider of choice
"port": 465,
"secure": true,
"auth": {
"user": "REPLACE-WITH-YOUR-ALIAS@YOURDOMAIN.COM",
"pass": "REPLACE-WITH-YOUR-GENERATED-PASSWORD"
}
}
}
```
Then, compile:
```
tsc && sass src/style:out/style && rollup -c
```
and start.
```
npm start
```
monofile should now be running on either `env.MONOFILE_PORT` or port `3000`.
## Disclaimer
Although we believe monofile is not against Discord's developer terms of service, monofile's contributors are not liable if Discord takes action against you for running an instance.
## License
Code written by monofile's contributors is currently licensed under [Unlicense](https://github.com/nbitzz/monofile/blob/main/LICENSE).
Icons under `/assets/icons` were created by Microsoft, and as such are licensed under [different terms](https://github.com/nbitzz/monofile/blob/1.3.0/assets/icons/README.md) (MIT).

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,34 @@
@font-face {
font-family: "Fira Code";
src: url("/static/assets/fonts/FiraCode-Light.ttf");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "Fira Code";
src: url("/static/assets/fonts/FiraCode-Regular.ttf");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Fira Code";
src: url("/static/assets/fonts/FiraCode-Medium.ttf");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "Fira Code";
src: url("/static/assets/fonts/FiraCode-SemiBold.ttf");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Fira Code";
src: url("/static/assets/fonts/FiraCode-Bold.ttf");
font-weight: 700;
font-style: normal;
}

View file

@ -0,0 +1,55 @@
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-ExtraLight.ttf");
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-Light.ttf");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-Regular.ttf");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-Medium.ttf");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-SemiBold.ttf");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-Bold.ttf");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-ExtraBold.ttf");
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: "Inconsolata";
src: url("/static/assets/fonts/Inconsolata-Black.ttf");
font-weight: 900;
font-style: normal;
}

1
assets/fonts/license Normal file
View file

@ -0,0 +1 @@
These fonts are licensed under the OFL

View file

@ -0,0 +1,41 @@
@font-face {
font-family: "Source Sans Pro";
src: url("/static/assets/fonts/SourceSansPro-Regular.ttf");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: url("/static/assets/fonts/SourceSansPro-Italic.ttf");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Source Sans Pro";
src: url("/static/assets/fonts/SourceSansPro-SemiBold.ttf");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: url("/static/assets/fonts/SourceSansPro-SemiBoldItalic.ttf");
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: "Source Sans Pro";
src: url("/static/assets/fonts/SourceSansPro-Bold.ttf");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: url("/static/assets/fonts/SourceSansPro-BoldItalic.ttf");
font-weight: 700;
font-style: italic;
}

28
assets/icons/README.md Normal file
View file

@ -0,0 +1,28 @@
These icons were originally distributed by Microsoft as part of the Fluent System UI icon collection.
https://github.com/microsoft/fluentui-system-icons
They are licensed under separate terms, those being:
```
MIT License
Copyright (c) 2020 Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2v6a2 2 0 0 0 2 2h6v10a2 2 0 0 1-2 2h-6.81A6.5 6.5 0 0 0 4 11.498V4a2 2 0 0 1 2-2h6Z" fill="#DDDDDD"/><path d="M13.5 2.5V8a.5.5 0 0 0 .5.5h5.5l-6-6ZM6.5 12a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm2.478 3.731-1.77 1.77 1.77 1.769a.5.5 0 1 1-.707.707l-1.77-1.77-1.769 1.768a.5.5 0 1 1-.707-.708L5.794 17.5 4.025 15.73a.5.5 0 1 1 .707-.707l1.77 1.769 1.77-1.769a.5.5 0 0 1 .706.707Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 510 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14 14.05V14H4.253a2.249 2.249 0 0 0-2.25 2.25v.919c0 .572.18 1.13.511 1.596C4.056 20.929 6.58 22 10 22c.715 0 1.39-.046 2.026-.14A2.51 2.51 0 0 1 12 21.5v-5a2.5 2.5 0 0 1 2-2.45ZM10 2.005a5 5 0 1 1 0 10 5 5 0 0 1 0-10ZM15 15v-1a2.5 2.5 0 0 1 5 0v1h.5a1.5 1.5 0 0 1 1.5 1.5v5a1.5 1.5 0 0 1-1.5 1.5h-6a1.5 1.5 0 0 1-1.5-1.5v-5a1.5 1.5 0 0 1 1.5-1.5h.5Zm1.5-1v1h2v-1a1 1 0 1 0-2 0Zm2 5a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 540 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.022 14A6.47 6.47 0 0 0 11 17.5c0 1.63.6 3.12 1.592 4.261-.796.16-1.66.24-2.592.24-3.42 0-5.944-1.072-7.486-3.237a2.75 2.75 0 0 1-.51-1.595v-.92a2.249 2.249 0 0 1 2.249-2.25h7.77Zm5.478-2a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm0 7.75a.625.625 0 1 0 0 1.25.625.625 0 0 0 0-1.25Zm0-5.876c-1.048 0-1.864.817-1.853 1.954a.5.5 0 1 0 1-.01c-.006-.579.36-.944.853-.944.473 0 .854.392.854.95 0 .192-.056.342-.224.56l-.094.117-.1.113-.265.29-.136.157c-.384.457-.535.793-.535 1.31a.5.5 0 1 0 1 0c0-.203.059-.359.239-.59l.085-.104.1-.116.267-.29.134-.155c.378-.45.529-.783.529-1.293 0-1.103-.823-1.95-1.854-1.95ZM10 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 772 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.95 8.6a6.554 6.554 0 0 1 6.55-6.55c3.596 0 6.55 2.819 6.55 6.45a6.554 6.554 0 0 1-6.55 6.55c-.531 0-1.055-.076-1.552-.204A1.25 1.25 0 0 1 12.7 16.05h-1.75v1.75c0 .69-.56 1.25-1.25 1.25H7.95v1.25a1.75 1.75 0 0 1-1.75 1.75H3.7a1.75 1.75 0 0 1-1.75-1.75v-2.172c0-.73.29-1.429.806-1.944L8.99 9.948a.275.275 0 0 0 .07-.244A6.386 6.386 0 0 1 8.95 8.6Zm9.3-1.6a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 529 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11 15c0-.35.06-.687.17-1H4.253a2.249 2.249 0 0 0-2.249 2.249v.92c0 .572.179 1.13.51 1.596C4.057 20.929 6.58 22 10 22c.397 0 .783-.014 1.156-.043A2.997 2.997 0 0 1 11 21v-6ZM10 2.005a5 5 0 1 1 0 10 5 5 0 0 1 0-10ZM12 15a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-6Zm2.5 1a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6Zm0 3a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 496 B

1
assets/icons/delete.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m4.21 4.387.083-.094a1 1 0 0 1 1.32-.083l.094.083L12 10.585l6.293-6.292a1 1 0 1 1 1.414 1.414L13.415 12l6.292 6.293a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.32.083l-.094-.083L12 13.415l-6.293 6.292a1 1 0 0 1-1.414-1.414L10.585 12 4.293 5.707a1 1 0 0 1-.083-1.32l.083-.094-.083.094Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 410 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.5 12a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm-5.478 2A6.47 6.47 0 0 0 11 17.5c0 1.644.61 3.145 1.617 4.29-.802.141-1.675.21-2.617.21-2.89 0-5.128-.656-6.691-2a3.75 3.75 0 0 1-1.305-2.843v-.907A2.25 2.25 0 0 1 4.254 14h7.768Zm3.071.966-.07.058-.057.07a.5.5 0 0 0 0 .568l.058.069 1.77 1.77-1.768 1.766-.057.07a.5.5 0 0 0 0 .568l.058.07.069.057a.5.5 0 0 0 .568 0l.07-.058 1.766-1.767 1.77 1.77.069.058a.5.5 0 0 0 .568 0l.07-.058.058-.07a.5.5 0 0 0 0-.568l-.058-.07-1.77-1.768 1.772-1.77.058-.07a.5.5 0 0 0 0-.568l-.058-.069-.069-.058a.5.5 0 0 0-.569 0l-.069.058-1.771 1.77-1.77-1.77-.07-.058a.5.5 0 0 0-.492-.043l-.076.043ZM10 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 791 B

1
assets/icons/error.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm3.53 6.47-.084-.073a.75.75 0 0 0-.882-.007l-.094.08L12 10.939l-2.47-2.47-.084-.072a.75.75 0 0 0-.882-.007l-.094.08-.073.084a.75.75 0 0 0-.007.882l.08.094L10.939 12l-2.47 2.47-.072.084a.75.75 0 0 0-.007.882l.08.094.084.073a.75.75 0 0 0 .882.007l.094-.08L12 13.061l2.47 2.47.084.072a.75.75 0 0 0 .882.007l.094-.08.073-.084a.75.75 0 0 0 .007-.882l-.08-.094L13.061 12l2.47-2.47.072-.084a.75.75 0 0 0 .007-.882l-.08-.094-.084-.073.084.073Z" fill="#ed8796"/></svg>

After

Width:  |  Height:  |  Size: 635 B

1
assets/icons/file.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2v6a2 2 0 0 0 2 2h6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h6Z" fill="#DDDDDD"/><path d="M13.5 2.5V8a.5.5 0 0 0 .5.5h5.5l-6-6Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2v6a2 2 0 0 0 2 2h6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h6Z" fill="#a6da95"/><path d="M13.5 2.5V8a.5.5 0 0 0 .5.5h5.5l-6-6Z" fill="#a6da95"/></svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m11.256 13 .238-1.5h1.481l-.237 1.5h-1.482Z" fill="#8aadf4"/><path d="M17.75 2.001a2.25 2.25 0 0 1 2.245 2.096L20 4.25v15.498a2.25 2.25 0 0 1-2.096 2.245l-.154.005H6.25a2.25 2.25 0 0 1-2.245-2.096L4 19.75V4.251a2.25 2.25 0 0 1 2.096-2.245l.154-.005h11.5Zm-5.355 13.16a.75.75 0 1 0 1.482.234l.142-.895h.731a.75.75 0 0 0 0-1.5h-.494l.238-1.5h.756a.75.75 0 0 0 0-1.5h-.519l.162-1.025a.75.75 0 1 0-1.481-.234l-.2 1.259h-1.48l.161-1.025a.75.75 0 1 0-1.481-.234l-.2 1.259H9.25a.75.75 0 1 0 0 1.5h.725L9.738 13H8.75a.75.75 0 1 0 0 1.5h.75l-.105.66a.75.75 0 0 0 1.482.235l.142-.895H12.5l-.105.66Z" fill="#8aadf4"/></svg>

After

Width:  |  Height:  |  Size: 716 B

1
assets/icons/image.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m11.475 13.718.083-.071a.75.75 0 0 1 .874-.007l.093.078 6.928 6.8A3.235 3.235 0 0 1 17.75 21H6.25a3.235 3.235 0 0 1-1.703-.481l6.928-6.801.083-.071-.083.07ZM17.75 3A3.25 3.25 0 0 1 21 6.25v11.5c0 .627-.178 1.213-.485 1.71l-6.939-6.813-.128-.116a2.25 2.25 0 0 0-2.889-.006l-.135.123-6.939 6.811A3.235 3.235 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm-1.998 3a2.252 2.252 0 1 0 0 4.504 2.252 2.252 0 0 0 0-4.504Zm0 1.5a.752.752 0 1 1 0 1.504.752.752 0 0 1 0-1.504Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 595 B

1
assets/icons/link.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.502 3.003a3.5 3.5 0 0 0-3.5 3.5v6a3.5 3.5 0 0 0 3.5 3.5H7v-2h-.497a1.5 1.5 0 0 1-1.5-1.5v-6a1.5 1.5 0 0 1 1.5-1.5h6a1.5 1.5 0 0 1 1.5 1.5v6a1.5 1.5 0 0 1-1.5 1.5h-1.506v2h1.506a3.5 3.5 0 0 0 3.5-3.5v-6a3.5 3.5 0 0 0-3.5-3.5h-6Z" fill="#DDDDDD"/><path d="M10 11.5a1.5 1.5 0 0 1 1.5-1.5h1.499V8H11.5A3.5 3.5 0 0 0 8 11.5v6a3.5 3.5 0 0 0 3.5 3.5h6a3.5 3.5 0 0 0 3.5-3.5v-6A3.5 3.5 0 0 0 17.5 8h-.495v2h.495a1.5 1.5 0 0 1 1.5 1.5v6a1.5 1.5 0 0 1-1.5 1.5h-6a1.5 1.5 0 0 1-1.5-1.5v-6Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 609 B

1
assets/icons/logout.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.25 2.75a1.5 1.5 0 0 0-1.5 1.5v15.5a1.5 1.5 0 0 0 1.5 1.5h5.94a6.5 6.5 0 0 1 7.06-10.012V4.25a1.5 1.5 0 0 0-1.5-1.5H6.25Zm2.25 10.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm9 9.75a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Zm3.5-5.5a.5.5 0 0 1-.5.5h-4.793l1.647 1.646a.5.5 0 0 1-.708.708l-2.5-2.5a.5.5 0 0 1 0-.708l2.5-2.5a.5.5 0 0 1 .708.708L15.707 17H20.5a.5.5 0 0 1 .5.5Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 494 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.207 2.543a1 1 0 0 1 0 1.414L5.414 5.75h7.836a8 8 0 1 1-8 8 1 1 0 1 1 2 0 6 6 0 1 0 6-6H5.414l1.793 1.793a1 1 0 0 1-1.414 1.414l-3.5-3.5a1 1 0 0 1 0-1.414l3.5-3.5a1 1 0 0 1 1.414 0Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 311 B

1
assets/icons/mail.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22 8.608v8.142a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75V8.608l9.652 5.056a.75.75 0 0 0 .696 0L22 8.608ZM5.25 4h13.5a3.25 3.25 0 0 1 3.234 2.924L12 12.154l-9.984-5.23a3.25 3.25 0 0 1 3.048-2.919L5.25 4h13.5-13.5Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 377 B

1
assets/icons/more.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM14 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM18 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 232 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.707 3.293a1 1 0 0 0-1.414 0L4 4.586l-.293-.293a1 1 0 0 0-1.414 1.414l1 1a1 1 0 0 0 1.414 0l2-2a1 1 0 0 0 0-1.414ZM10 16.993h11.003a1 1 0 0 1 .117 1.994l-.117.006H10A1 1 0 0 1 9.883 17l.117-.007ZM10 11h11.003a1 1 0 0 1 .117 1.993l-.117.007H10a1 1 0 0 1-.117-1.993L10 11Zm0-6h11.003a1 1 0 0 1 .117 1.993L21.003 7H10a1 1 0 0 1-.117-1.993L10 5ZM5.293 16.293a1 1 0 0 1 1.414 1.414l-2 2a1 1 0 0 1-1.414 0l-1-1a1 1 0 1 1 1.414-1.414l.293.293 1.293-1.293Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 578 B

1
assets/icons/paint.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2.25a.75.75 0 0 0-1.5 0V3.5a2.24 2.24 0 0 0-.841.53L2.78 10.91a2.25 2.25 0 0 0 0 3.182L7.66 18.97a2.25 2.25 0 0 0 3.182 0l6.879-6.879a2.25 2.25 0 0 0 0-3.182L12.84 4.03A2.24 2.24 0 0 0 12 3.5V2.25Zm-1.5 3.06v1.44a.75.75 0 0 0 1.5 0V5.31l4.659 4.66a.75.75 0 0 1 0 1.06l-.97.97H3.812l.029-.03L10.5 5.31ZM19.521 13.602a.874.874 0 0 0-1.542 0l-2.008 3.766C14.85 19.466 16.372 22 18.75 22s3.898-2.534 2.78-4.632l-2.009-3.766Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 552 B

1
assets/icons/person.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.918a2.75 2.75 0 0 1-.513 1.599C17.945 20.929 15.42 22 12 22c-3.422 0-5.945-1.072-7.487-3.237a2.75 2.75 0 0 1-.51-1.595v-.92a2.249 2.249 0 0 1 2.249-2.25h11.501ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 369 B

1
assets/icons/pound.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10.985 3.165a1 1 0 0 0-1.973-.33l-.86 5.163L3.998 8a1 1 0 1 0 .002 2l3.817-.002-.667 4L3 14a1 1 0 1 0 0 2l3.817-.002-.807 4.838a1 1 0 1 0 1.973.329l.862-5.167 4.975-.003-.806 4.84a1 1 0 1 0 1.972.33l.862-5.17L20 15.992a1 1 0 0 0 0-2l-3.819.001.667-4.001L21 9.99a1 1 0 0 0 0-2l-3.818.002.804-4.827a1 1 0 1 0-1.972-.33l-.86 5.159-4.975.003.806-4.832Zm-1.14 6.832 4.976-.003-.667 4.001-4.976.002.667-4Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 528 B

1
assets/icons/private.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-6.95-6.946.002-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 592 B

1
assets/icons/public.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 307 B

1
assets/icons/refresh.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10Zm3.27-11.25H14a.75.75 0 0 0 0 1.5h2.75a.75.75 0 0 0 .75-.75V8.25a.75.75 0 0 0-1.5 0V9a4.991 4.991 0 0 0-4-2c-1.537 0-2.904.66-3.827 1.77a.75.75 0 0 0 1.154.96C9.963 8.963 10.907 8.5 12 8.5c1.492 0 2.767.934 3.27 2.25Zm-7.27 5V15a5.013 5.013 0 0 0 7.821.237.75.75 0 1 0-1.142-.972 3.513 3.513 0 0 1-5.842-.765H10a.75.75 0 0 0 0-1.5H7.25a.75.75 0 0 0-.75.75v3a.75.75 0 0 0 1.5 0Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 578 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M21.25 13a.75.75 0 0 1 .743.648l.007.102v5a3.25 3.25 0 0 1-3.066 3.245L18.75 22h-4.668c.536-.385.973-.9 1.265-1.499l3.403-.001a1.75 1.75 0 0 0 1.744-1.607l.006-.143v-5a.75.75 0 0 1 .75-.75ZM9.447 17.165l.114.103 4.085 4.086.023.019A3.235 3.235 0 0 1 11.75 22h-6.5a3.235 3.235 0 0 1-1.92-.627l.024-.02 4.085-4.085.114-.103a1.5 1.5 0 0 1 1.894 0ZM11.75 9A3.25 3.25 0 0 1 15 12.25v6.5c0 .718-.233 1.382-.627 1.92l-.02-.024-4.085-4.085-.13-.122a2.5 2.5 0 0 0-3.269-.006l-.137.128-4.086 4.085-.019.023A3.235 3.235 0 0 1 2 18.75v-6.5A3.25 3.25 0 0 1 5.25 9h6.5ZM11 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm7.75-10a3.25 3.25 0 0 1 3.245 3.066L22 5.25v5a.75.75 0 0 1-1.493.102l-.007-.102v-5a1.75 1.75 0 0 0-1.606-1.744L18.75 3.5h-5a.75.75 0 0 1-.102-1.493L13.75 2h5Zm-8.5 0a.75.75 0 0 1 .102 1.493l-.102.007h-5a1.75 1.75 0 0 0-1.744 1.606L3.5 5.25v3.402c-.6.292-1.114.73-1.5 1.266V5.25a3.25 3.25 0 0 1 3.066-3.245L5.25 2h5Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
assets/icons/tag.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-8.5 8.503a3.255 3.255 0 0 1-4.597.001L3.489 16.06a3.25 3.25 0 0 1-.003-4.596l8.5-8.51A3.25 3.25 0 0 1 14.284 2h5.465ZM17 5.502a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 358 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-.026.026a6.5 6.5 0 0 0-9.028 8.92 3.256 3.256 0 0 1-4.043-.442L3.489 16.06a3.25 3.25 0 0 1-.004-4.596l8.5-8.51a3.25 3.25 0 0 1 2.3-.953h5.465ZM17 5.502a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM23 17.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0Zm-7.147-2.354a.5.5 0 0 0-.707.708l1.647 1.646-1.647 1.646a.5.5 0 0 0 .707.708l1.647-1.647 1.646 1.647a.5.5 0 0 0 .707-.708L18.207 17.5l1.646-1.646a.5.5 0 0 0-.707-.708L17.5 16.793l-1.647-1.647Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 623 B

1
assets/icons/update.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22 12.001c0-5.523-4.476-10-10-10-5.522 0-10 4.477-10 10s4.478 10 10 10c5.524 0 10-4.477 10-10Zm-14.53.28a.75.75 0 0 1-.073-.976l.073-.085 4-4a.75.75 0 0 1 .977-.073l.085.073 4 4.001a.75.75 0 0 1-.977 1.133l-.084-.072-2.72-2.722v6.691a.75.75 0 0 1-.649.744L12 17a.75.75 0 0 1-.743-.648l-.007-.102v-6.69l-2.72 2.72a.75.75 0 0 1-.976.073l-.084-.073Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 475 B

View file

@ -1,30 +0,0 @@
document.getElementById("uploadButton").addEventListener("click",() => {
let ask = prompt("Input a URL to clone.")
if (ask) {
let opt = getOptionsForUploading()
updateBtnTxt("Requesting clone. Please wait.")
let xmlhttp = new XMLHttpRequest()
xmlhttp.addEventListener("error",function(e) {
updateBtnTxt(`Upload failed.<br/>${e.toString()}`)
console.error(e)
})
xmlhttp.addEventListener("load",function() {
if (xmlhttp.status == 200) {
document.getElementById("CopyTB").value = `https://${location.hostname}/download/${xmlhttp.responseText}`
updateBtnTxt(`Upload complete.<br/><a style="color:blue;font-family:monospace;" href="javascript:document.getElementById('CopyTB').focus();document.getElementById('CopyTB').select();document.execCommand('copy');document.getElementById('CopyTB').blur();">Copy URL</a> <a style="color:blue;font-family:monospace;" href="javascript:prompt('This is your download URL.', document.getElementById('CopyTB').value);null">View URL</a>`)
} else {
updateBtnTxt(`Upload failed.<br/>${xmlhttp.responseText}`)
}
})
xmlhttp.open("POST","/clone")
xmlhttp.send(JSON.stringify({
url: ask,
...opt
}))
}
})

View file

@ -1,37 +0,0 @@
let FileUpload = document.createElement("input")
FileUpload.setAttribute("type","file")
document.getElementById("uploadButton").addEventListener("click",() => FileUpload.click())
FileUpload.addEventListener("input",() => {
if (FileUpload.files[0]) {
let opt = getOptionsForUploading()
let file = FileUpload.files[0]
updateBtnTxt("Uploading file. This may take a while, so stay put.")
let xmlhttp = new XMLHttpRequest()
xmlhttp.addEventListener("error",function(e) {
updateBtnTxt(`Upload failed.<br/>${e.toString()}`)
console.error(e)
})
xmlhttp.addEventListener("load",function() {
if (xmlhttp.status == 200) {
document.getElementById("CopyTB").value = `https://${location.hostname}/download/${xmlhttp.responseText}`
updateBtnTxt(`Upload complete.<br/><a style="color:blue;font-family:monospace;" href="javascript:document.getElementById('CopyTB').focus();document.getElementById('CopyTB').select();document.execCommand('copy');document.getElementById('CopyTB').blur();">Copy URL</a> <a style="color:blue;font-family:monospace;" href="javascript:prompt('This is your download URL.', document.getElementById('CopyTB').value);null">View URL</a>`)
} else {
updateBtnTxt(`Upload failed.<br/>${xmlhttp.responseText}`)
}
})
let fd = new FormData()
fd.append('file',file)
xmlhttp.open("POST","/upload")
xmlhttp.setRequestHeader("monofile-upload-id",opt.uploadId)
xmlhttp.send(fd)
}
})

View file

@ -1,7 +1,28 @@
{
"maxDiscordFiles": 20,
"maxDiscordFileSize": 8388608,
"targetGuild": "1024080490677936248",
"targetChannel": "1024080525993971913",
"requestTimeout":120000
{
"maxDiscordFiles": 20,
"maxDiscordFileSize": 26214400,
"targetGuild": "1024080490677936248",
"targetChannel": "1024080525993971913",
"requestTimeout":120000,
"maxUploadIdLength":30,
"accounts": {
"registrationEnabled": true,
"requiredForUpload": false
},
"webdrop": {
"accountRequired": false
},
"mail": {
"transport": {
"host": "smtp.fastmail.com",
"port": 465,
"secure": true
},
"send": {
"from": "mono@fyle.uk"
}
}
}

5021
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,41 @@
{
"name": "monofile",
"version": "1.2.3",
"description": "Discord-based file sharing",
"main": "index.js",
"scripts": {
"start": "node ./out/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "Unlicense",
"engines": {
"node": ">=v18"
},
"dependencies": {
"@types/body-parser": "^1.19.2",
"@types/express": "^4.17.14",
"@types/multer": "^1.4.7",
"axios": "^0.27.2",
"body-parser": "^1.20.0",
"discord.js": "^14.7.1",
"dotenv": "^16.0.2",
"express": "^4.18.1",
"multer": "^1.4.5-lts.1",
"typescript": "^4.8.3"
}
}
{
"name": "monofile",
"version": "1.3.0",
"description": "Discord-based file sharing",
"main": "index.js",
"scripts": {
"start": "node ./out/server/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "nbitzz",
"license": "Unlicense",
"engines": {
"node": ">=v18"
},
"dependencies": {
"@types/body-parser": "^1.19.2",
"@types/express": "^4.17.14",
"@types/multer": "^1.4.7",
"@types/nodemailer": "^6.4.8",
"axios": "^0.27.2",
"body-parser": "^1.20.0",
"bytes": "^3.1.2",
"cookie-parser": "^1.4.6",
"discord.js": "^14.7.1",
"dotenv": "^16.0.2",
"express": "^4.18.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.3",
"typescript": "^4.8.3"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
"@types/bytes": "^3.1.1",
"@types/cookie-parser": "^1.4.3",
"rollup": "^3.11.0",
"rollup-plugin-svelte": "^7.1.0",
"sass": "^1.57.1",
"svelte": "^3.55.1"
}
}

View file

@ -1,173 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>monofile</title>
<meta name="og:site_name" content="$Version">
<meta name="application-name" content="monofile">
<meta name="description" content="Discord-based filesharing">
<style>
* {
font-family: sans-serif;
}
h1,h2 {
text-align:center;
}
#content {
position:fixed;
left:50%;
top:50%;
transform:translate(-50%,-50%);
background-color:white;
width:450px;
/*height:100%;*/
}
body {
background-color: darkgray;
}
#btnContainer {
width:calc( 100% - 50px );
height:50px;
position:relative;
left:50%;
transform:translate(-50%,0);
}
button {
color:black;
font-weight:bold;
border:none;
position:absolute;
font-size:20px;
}
button:hover {
border: 1px solid gray;
}
#uploadButton {
width:calc( 100% - 50px );
height:100%;
background-color: #66AAFF;
left:0px;
top:0px;
}
#optionsButton {
width:50px;
height:100%;
background-color: #AAAAAA;
left:calc( 100% - 50px );
top:0px;
font-size:15px;
}
.note {
padding:5px;
position:relative;
width:calc( 100% - 62px );
left:25px;
border:1px solid #AAAAAAFF;
border-radius: 8px;
background-color: #AAAAAA66;
}
/* this is horrible css lmao */
#options > input {
font-family:monospace;
width:250px;
font-size:14px;
border: 1px solid #777777;
background-color:#AAAAAA;
outline:none;
text-align:center;
}
#options {
height:30px;
display:none;
}
#customId {
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%)
}
@media only screen and (max-width: 450px) {
#content {
position:fixed;
left:0%;
top:0%;
transform:translate(0%,0%);
background-color:white;
width:100%;
height:100%;
}
}
</style>
</head>
<body>
<input type="text" id="CopyTB" value="" readonly style="opacity:0;width:10px;height:0%;">
<div id="content">
<h1>
monofile<span style="font-style:italic;font-weight:bold;font-size:16px;color:#999999"> $Version</span>
</h1>
<h2><em>Discord-based file sharing</em></h2>
<div class="note" style="border-color:#FFAAAAFF;background-color:#FFAAAA66">
Before sharing files, please remember:
<ul>
<li>Do NOT share sensitive information via monofile</li>
<li>Do NOT share illegal content via monofile</li>
<li>The owner of this instance reserves the right to remove your files</li>
</ul>
</div>
<div style="width:100%;height:25px"></div>
<div id="btnContainer">
<button id="uploadButton">$UploadButtonText</button>
<button id="optionsButton">• • •</button>
</div>
<div id="options" class="note" style="border-radius:0px;">
<input id="customId" autocomplete="off" placeholder = "custom id (30char, no spaces)" maxlength="30">
</div>
<p style="font-family:monospace;position:relative;width:calc( 100% - 50px );left:25px;text-align: center;">
Max filesize on instance: $MaxInstanceFilesize
<br />
Hosting <strong style="font-family:monospace">$FileNum</strong> files
</p>
<p style="font-family:monospace;position:relative;width:calc( 100% - 50px );left:25px;text-align: center;font-size:12px;color:gray;">made by nbitzz — <a style="color:gray;font-family:monospace;font-size:12px;" href="https://github.com/nbitzz/monofile">github</a><a style="color:gray;font-family:monospace;font-size:12px;" href="$otherPath">$otherText</a></p>
<div style="width:100%;height:25px"></div>
</div>
</body>
<!-- bad thing to do but i'm being rushed -->
<script>
let uploading = false
const updateBtnTxt = (btntxt) => {document.getElementById("btnContainer").innerHTML = `<div class="note" style="width:calc( 100% - 10px );height:calc( 100% - 10px );position:absolute;left:0px;top:0px;"><p style="font-family:monospace;position:relative;width:calc( 100% - 50px );left:25px;text-align: center;">${btntxt}</p></div>`}
const getOptionsForUploading = () => {
uploading = true
document.getElementById("options").style.display = "none"
return {
uploadId: document.getElementById("customId").value
}
}
document.getElementById("optionsButton").addEventListener("click",() => {
let display = document.getElementById("options").style.display
if (!uploading) document.getElementById("options").style.display = !display || display == "none" ? "block" : "none"
})
</script>
<script src="/static/script/$Handler.js"></script>
</html>

View file

@ -1,102 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>monofile</title>
<meta name="og:site_name" content="monofile $Version">
<meta name="title" content="$FileName">
<meta name="description" content="ID: $FileId">
<style>
* {
font-family: sans-serif;
}
h1,h2 {
text-align:center;
}
#content {
position:fixed;
left:50%;
top:50%;
transform:translate(-50%,-50%);
background-color:white;
width:450px;
/*height:100%;*/
}
body {
background-color: darkgray;
}
#btnContainer {
width:calc( 100% - 50px );
height:50px;
position:relative;
left:50%;
transform:translate(-50%,0);
}
#dlbtn {
width:100%;
height:100%;
background-color: #66AAFF;
border:none;
position:absolute;
left:0px;
top:0px;
text-decoration: none;
display:flex; /* This is a mess but I give up. */
flex-direction: column;
justify-content: center;
}
#btnContainer:hover {
outline: 1px solid gray;
}
#dlbtn > p {
color:black;
font-weight:bold;
font-size:20px;
text-align: center;
width:100%;
line-height:20px;
}
.note {
padding:5px;
position:relative;
width:calc( 100% - 60px );
left:25px;
border:1px solid #AAAAAAFF;
border-radius: 8px;
background-color: #AAAAAA66;
}
@media only screen and (max-width: 450px) {
#content {
position:fixed;
left:0%;
top:0%;
transform:translate(0%,0%);
background-color:white;
width:100%;
height:100%;
}
}
</style>
</head>
<body>
<div id="content">
<h1>
$FileName
</h1>
<h2 style="font-family:monospace">file id: $FileId</h2>
<div id="btnContainer"><a id="dlbtn" href="/file/$FileId" download="$FileName"><p>Download file</p></a></div>
<p style="font-family:monospace;position:relative;width:calc( 100% - 50px );left:25px;text-align: center;">
May take a while to download. Stay put.
</p>
<div style="width:100%;height:25px"></div>
</div>
</body>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$FileId</title>
<!--metaTags-->
<meta name="og:site_name" content="$Uploader">
<meta name="title" content="$FileName">
<meta name="description" content="$FileSize file on monofile $Version, the Discord-based file sharing service">
<link
rel="stylesheet"
href="/static/style/downloads.css"
>
<link
rel="stylesheet"
href="/auth/customCSS"
>
<link
rel="icon"
type="image/svg"
href="/static/assets/icons/file_icon.svg"
>
</head>
<body>
<div id="appContent">
<div id="uploadWindow">
<h1>
$FileName
</h1>
<p style="color:#999999">
<span class="number">$FileSize</span>&nbsp;&nbsp;&nbsp;&nbsp;uploaded by <span class="number">$Uploader</span>
</p>
<!--preview-->
<button style="position:relative;width:100%;top:10px;">
<a id="dlbtn" href="/file/$FileId" download="$FileName" style="position:absolute;left:0px;top:0px;height:100%;width:100%;"></a>
download
</button>
<div style="min-height:15px" />
</div>
</div>
</body>
</html>

View file

@ -1,72 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="og:site_name" content="monofile $Version">
<meta name="application-name" content="$ErrorCode">
<meta name="description" content="$ErrorMessage">
<title>monofile</title>
<style>
* {
font-family: sans-serif;
}
h1,h2 {
text-align:center;
}
#content {
position:fixed;
left:50%;
top:50%;
transform:translate(-50%,-50%);
background-color:white;
width:450px;
/*height:100%;*/
}
body {
background-color: darkgray;
}
.note {
padding:5px;
position:relative;
width:calc( 100% - 60px );
left:25px;
border:1px solid #AAAAAAFF;
border-radius: 8px;
background-color: #AAAAAA66;
}
@media only screen and (max-width: 450px) {
#content {
position:fixed;
left:0%;
top:0%;
transform:translate(0%,0%);
background-color:white;
width:100%;
height:100%;
}
}
</style>
</head>
<body>
<div id="content">
<h1>
monofile<span style="font-style:italic;font-weight:bold;font-size:16px;color:#999999"> $Version</span>
</h1>
<h2><em>Discord-based file sharing</em></h2>
<div class="note" style="border-color:#FFAAAAFF;background-color:#FFAAAA66">
<h1>$ErrorCode</h1>
<p style="width:calc( 100% - 50px );left:25px;position:relative;">$ErrorMessage</p>
</div>
<div style="width:100%;height:25px"></div>
</div>
</body>
<!DOCTYPE html>
<html lang="en">
<head>
<link
rel="stylesheet"
href="/static/style/error.css"
>
<link
rel="icon"
type="image/svg"
href="/static/assets/icons/error.svg"
>
<link
rel="stylesheet"
href="/auth/customCSS"
>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=0"
>
<title>$code</title>
<meta name="theme-color" content="rgb(30, 33, 36)">
</head>
<body>
<p class="error">
<span class="code">$code</span>
&nbsp;$text
</p>
</body>
</html>

47
pages/index.html Normal file
View file

@ -0,0 +1,47 @@
<!--
for some reason (don't know why)
certain things break
when not in quirks mode
so i'm not adding in the
doctype html
-->
<html lang="en">
<head>
<link
rel="stylesheet"
href="/static/style/app.css"
>
<link
rel="icon"
type="image/svg"
href="/static/assets/icons/icon_temp.svg"
>
<link
rel="stylesheet"
href="/auth/customCSS"
>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=0"
>
<script type="module" src="/static/js/index.js"></script>
<title>monofile</title>
<meta name="title" content="monofile">
<meta name="description" content="The open-source Discord-based file sharing service">
<meta name="theme-color" content="rgb(30, 33, 36)">
</head>
<body>
</body>
</html>

17
rollup.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import svelte from 'rollup-plugin-svelte'
import resolve from "@rollup/plugin-node-resolve"
export default [
{
input: "src/client/index.js",
output: {
file: 'out/client/index.js',
format: 'esm',
sourcemap:true
},
plugins: [
resolve({ browser: true }),
svelte({})
]
}
]

5
src/client/index.js Normal file
View file

@ -0,0 +1,5 @@
import App from "../svelte/App.svelte"
new App({
target: document.body
})

View file

@ -1,172 +0,0 @@
/*
i really should split this up into different modules
*/
import bodyParser from "body-parser"
import multer, {memoryStorage} from "multer"
import Discord, { IntentsBitField, Client } from "discord.js"
import express from "express"
import fs from "fs"
import axios, { AxiosResponse } from "axios"
import Files from "./lib/files"
require("dotenv").config()
const multerSetup = multer({storage:memoryStorage()})
let pkg = require(`${process.cwd()}/package.json`)
let app = express()
let config = require(`${process.cwd()}/config.json`)
app.use("/static",express.static("assets"))
app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
// funcs
function ThrowError(response:express.Response,code:number,errorMessage:string) {
fs.readFile(__dirname+"/../pages/error.html",(err,buf) => {
if (err) {response.sendStatus(500);console.log(err);return}
response.status(code)
response.send(buf.toString().replace(/\$ErrorCode/g,code.toString()).replace(/\$ErrorMessage/g,errorMessage).replace(/\$Version/g,pkg.version))
})
}
// init data
if (!fs.existsSync(__dirname+"/../.data/")) fs.mkdirSync(__dirname+"/../.data/")
// discord
let client = new Client({intents:[
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.MessageContent
],rest:{timeout:config.requestTimeout}})
let files = new Files(client,config)
// routes (could probably make these use routers)
// index, clone
app.get("/", function(req,res) {
fs.readFile(__dirname+"/../pages/base.html",(err,buf) => {
if (err) {res.sendStatus(500);console.log(err);return}
res.send(
buf.toString()
.replace("$MaxInstanceFilesize",`${(config.maxDiscordFileSize*config.maxDiscordFiles)/1048576}MB`)
.replace(/\$Version/g,pkg.version)
.replace(/\$Handler/g,"upload_file")
.replace(/\$UploadButtonText/g,"Upload file")
.replace(/\$otherPath/g,"/clone")
.replace(/\$otherText/g,"clone from url...")
.replace(/\$FileNum/g,Object.keys(files.files).length.toString())
)
})
})
app.get("/clone", function(req,res) {
fs.readFile(__dirname+"/../pages/base.html",(err,buf) => {
if (err) {res.sendStatus(500);console.log(err);return}
res.send(
buf.toString()
.replace("$MaxInstanceFilesize",`${(config.maxDiscordFileSize*config.maxDiscordFiles)/1048576}MB`)
.replace(/\$Version/g,pkg.version)
.replace(/\$Handler/g,"clone_file")
.replace(/\$UploadButtonText/g,"Input a URL")
.replace(/\$otherPath/g,"/")
.replace(/\$otherText/g,"upload file...")
.replace(/\$FileNum/g,Object.keys(files.files).length.toString())
)
})
})
// upload handlers
app.post("/upload",multerSetup.single('file'),async (req,res) => {
if (req.file) {
try {
files.uploadFile({name:req.file.originalname,mime:req.file.mimetype,uploadId:req.header("monofile-upload-id")},req.file.buffer)
.then((uID) => res.send(uID))
.catch((stat) => {res.status(stat.status);res.send(`[err] ${stat.message}`)})
} catch {
res.status(400)
res.send("[err] bad request")
}
} else {
res.status(400)
res.send("[err] bad request")
}
})
app.post("/clone",(req,res) => {
try {
let j = JSON.parse(req.body)
if (!j.url) {
res.status(400)
res.send("[err] invalid url")
}
axios.get(j.url,{responseType:"arraybuffer"}).then((data:AxiosResponse) => {
files.uploadFile({name:j.url.split("/")[req.body.split("/").length-1] || "generic",mime:data.headers["content-type"],uploadId:j.uploadId},Buffer.from(data.data))
.then((uID) => res.send(uID))
.catch((stat) => {res.status(stat.status);res.send(`[err] ${stat.message}`)})
}).catch((err) => {
console.log(err)
res.status(400)
res.send(`[err] failed to fetch data`)
})
} catch {
res.status(500)
res.send("[err] an error occured")
}
})
// serve files & download page
app.get("/download/:fileId",(req,res) => {
if (files.getFilePointer(req.params.fileId)) {
let file = files.getFilePointer(req.params.fileId)
fs.readFile(__dirname+"/../pages/download.html",(err,buf) => {
if (err) {res.sendStatus(500);console.log(err);return}
res.send(
buf.toString()
.replace(/\$FileId/g,req.params.fileId)
.replace(/\$Version/g,pkg.version)
.replace(/\$FileName/g,
file.filename
.replace(/\&/g,"&amp;")
.replace(/\</g,"&lt;")
.replace(/\>/g,"&gt;")
)
)
})
} else {
ThrowError(res,404,"File not found.")
}
})
app.get("/file/:fileId",async (req,res) => {
files.readFileStream(req.params.fileId).then(f => {
res.setHeader("Content-Type",f.contentType)
res.status(200)
f.dataStream.pipe(res)
}).catch((err) => {
ThrowError(res,err.status,err.message)
})
})
app.get("*",(req,res) => {
ThrowError(res,404,"Page not found.")
})
app.get("/server",(req,res) => {
res.send(JSON.stringify({...config,version:pkg.version}))
})
// listen on 3000 or MONOFILE_PORT
app.listen(process.env.MONOFILE_PORT || 3000,function() {
console.log("Web OK!")
})
client.login(process.env.TOKEN)

View file

@ -1,246 +0,0 @@
import axios from "axios";
import Discord, { Client, TextBasedChannel } from "discord.js";
import { readFile, writeFile } from "fs";
import { Readable } from "node:stream"
export let id_check_regex = /[A-Za-z0-9_\-\.]+/
export interface FileUploadSettings {
name?: string,
mime: string,
uploadId?: string
}
export interface Configuration {
maxDiscordFiles: number,
maxDiscordFileSize: number,
targetGuild: string,
targetChannel: string,
requestTimeout: number
}
export interface FilePointer {
filename:string,
mime:string,
messageids:string[]
}
export interface StatusCodeError {
status: number,
message: string
}
/* */
export default class Files {
config: Configuration
client: Client
files: {[key:string]:FilePointer} = {}
uploadChannel?: TextBasedChannel
constructor(client: Client, config: Configuration) {
this.config = config;
this.client = client;
client.on("ready",() => {
console.log("Discord OK!")
client.guilds.fetch(config.targetGuild).then((g) => {
g.channels.fetch(config.targetChannel).then((a) => {
if (a?.isTextBased()) {
this.uploadChannel = a
}
})
})
})
readFile(process.cwd()+"/.data/files.json",(err,buf) => {
if (err) {console.log(err);return}
this.files = JSON.parse(buf.toString() || "{}")
})
}
uploadFile(settings:FileUploadSettings,fBuffer:Buffer):Promise<string|StatusCodeError> {
return new Promise<string>(async (resolve,reject) => {
if (!this.uploadChannel) {
reject({status:503,message:"server is not ready - please try again later"})
return
}
if (!settings.name || !settings.mime) {
reject({status:400,message:"missing name/mime"});
return
}
let uploadId = (settings.uploadId || Math.random().toString().slice(2)).toString();
if ((uploadId.match(id_check_regex) || [])[0] != uploadId || uploadId.length > 30) {
reject({status:400,message:"invalid id"});return
}
if (this.files[uploadId]) {
reject({status:400,message:"a file with this id already exists"});
return
}
if (settings.name.length > 128) {
reject({status:400,message:"name too long"});
return
}
if (settings.mime.length > 128) {
reject({status:400,message:"mime too long"});
return
}
// get buffer
if (fBuffer.byteLength >= (this.config.maxDiscordFileSize*this.config.maxDiscordFiles)) {
reject({status:400,message:"file too large"});
return
}
// generate buffers to upload
let toUpload = []
for (let i = 0; i < Math.ceil(fBuffer.byteLength/this.config.maxDiscordFileSize); i++) {
toUpload.push(
fBuffer.subarray(
i*this.config.maxDiscordFileSize,
Math.min(
fBuffer.byteLength,
(i+1)*this.config.maxDiscordFileSize
)
)
)
}
// begin uploading
let uploadTmplt:Discord.AttachmentBuilder[] = toUpload.map((e) => {
return new Discord.AttachmentBuilder(e)
.setName(Math.random().toString().slice(2))
})
let uploadGroups = []
for (let i = 0; i < Math.ceil(uploadTmplt.length/10); i++) {
uploadGroups.push(uploadTmplt.slice(i*10,((i+1)*10)))
}
let msgIds = []
for (let i = 0; i < uploadGroups.length; i++) {
let ms = await this.uploadChannel.send({
files:uploadGroups[i]
}).catch((e) => {console.error(e)})
if (ms) {
msgIds.push(ms.id)
} else {
reject({status:500,message:"please try again"}); return
}
}
// save
resolve(await this.writeFile(
uploadId,
{
filename:settings.name,
messageids:msgIds,
mime:settings.mime
}
))
})
}
// fs
writeFile(uploadId: string, file: FilePointer):Promise<string> {
return new Promise((resolve, reject) => {
this.files[uploadId] = file
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
if (err) {
reject({status:500,message:"please try again"});
delete this.files[uploadId];
return
}
resolve(uploadId)
})
})
}
// todo: move read code here
readFileStream(uploadId: string):Promise<{dataStream:Readable,contentType:string}> {
return new Promise(async (resolve,reject) => {
if (!this.uploadChannel) {
reject({status:503,message:"server is not ready - please try again later"})
return
}
if (this.files[uploadId]) {
let file = this.files[uploadId]
let dataStream = new Readable({
read(){}
})
resolve({
contentType: file.mime,
dataStream: dataStream
})
for (let i = 0; i < file.messageids.length; i++) {
let msg = await this.uploadChannel.messages.fetch(file.messageids[i]).catch(() => {return null})
if (msg?.attachments) {
let attach = Array.from(msg.attachments.values())
for (let i = 0; i < attach.length; i++) {
let d = await axios.get(attach[i].url,{responseType:"arraybuffer"}).catch((e:Error) => {console.error(e)})
if (d) {
dataStream.push(d.data)
} else {
reject({status:500,message:"internal server error"})
dataStream.destroy(new Error("file read error"))
return
}
}
}
}
dataStream.push(null)
} else {
reject({status:404,message:"not found"})
}
})
}
unlink(uploadId:string):Promise<void> {
return new Promise((resolve,reject) => {
let tmp = this.files[uploadId];
delete this.files[uploadId];
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
if (err) {
this.files[uploadId] = tmp
reject()
} else {
resolve()
}
})
})
}
getFilePointer(uploadId:string):FilePointer {
return this.files[uploadId]
}
}

164
src/server/index.ts Normal file
View file

@ -0,0 +1,164 @@
import cookieParser from "cookie-parser";
import { IntentsBitField, Client } from "discord.js"
import express from "express"
import fs from "fs"
import bytes from "bytes";
import ServeError from "./lib/errors"
import Files from "./lib/files"
import * as auth from "./lib/auth"
import * as Accounts from "./lib/accounts"
import * as authRoutes from "./routes/authRoutes";
import * as fileApiRoutes from "./routes/fileApiRoutes";
import * as adminRoutes from "./routes/adminRoutes";
import * as primaryApi from "./routes/primaryApi";
require("dotenv").config()
let pkg = require(`${process.cwd()}/package.json`)
let app = express()
let config = require(`${process.cwd()}/config.json`)
app.use("/static/assets",express.static("assets"))
app.use("/static/style",express.static("out/style"))
app.use("/static/js",express.static("out/client"))
//app.use(bodyParser.text({limit:(config.maxDiscordFileSize*config.maxDiscordFiles)+1048576,type:["application/json","text/plain"]}))
app.use(cookieParser())
app.get("/server",(req,res) => {
res.send(JSON.stringify({
...config,
version:pkg.version,
files:Object.keys(files.files).length
}))
})
app
.use("/auth",authRoutes.authRoutes)
.use("/admin",adminRoutes.adminRoutes)
.use("/files", fileApiRoutes.fileApiRoutes)
.use(primaryApi.primaryApi)
// funcs
// init data
if (!fs.existsSync(__dirname+"/../.data/")) fs.mkdirSync(__dirname+"/../.data/")
// discord
let client = new Client({intents:[
IntentsBitField.Flags.GuildMessages,
IntentsBitField.Flags.MessageContent
],rest:{timeout:config.requestTimeout}})
let files = new Files(client,config)
authRoutes.setFilesObj(files)
adminRoutes.setFilesObj(files)
fileApiRoutes.setFilesObj(files)
primaryApi.setFilesObj(files)
// routes (could probably make these use routers)
// index, clone
app.get("/", function(req,res) {
res.sendFile(process.cwd()+"/pages/index.html")
})
// serve download page
app.get("/download/:fileId",(req,res) => {
if (files.getFilePointer(req.params.fileId)) {
let file = files.getFilePointer(req.params.fileId)
if (file.visibility == "private" && Accounts.getFromToken(req.cookies.auth)?.id != file.owner) {
ServeError(res,403,"you do not own this file")
return
}
fs.readFile(process.cwd()+"/pages/download.html",(err,buf) => {
let fileOwner = file.owner ? Accounts.getFromId(file.owner) : undefined;
if (err) {res.sendStatus(500);console.log(err);return}
res.send(
buf.toString()
.replace(/\$FileId/g,req.params.fileId)
.replace(/\$Version/g,pkg.version)
.replace(/\$FileSize/g,file.sizeInBytes ? bytes(file.sizeInBytes) : "[File size unknown]")
.replace(/\$FileName/g,
file.filename
.replace(/\&/g,"&amp;")
.replace(/\</g,"&lt;")
.replace(/\>/g,"&gt;")
)
.replace(/\<\!\-\-metaTags\-\-\>/g,
(
file.mime.startsWith("image/")
? `<meta name="og:image" content="https://${req.headers.host}/file/${req.params.fileId}" />`
: (
file.mime.startsWith("video/")
? (
`<meta property="og:video:url" content="https://${req.headers.host}/cpt/${req.params.fileId}/video.${file.mime.split("/")[1] == "quicktime" ? "mov" : file.mime.split("/")[1]}" />
<meta property="og:video:secure_url" content="https://${req.headers.host}/cpt/${req.params.fileId}/video.${file.mime.split("/")[1] == "quicktime" ? "mov" : file.mime.split("/")[1]}" />
<meta property="og:type" content="video.other">
<!-- honestly probably good enough for now -->
<meta property="twitter:image" content="0">`
// quick lazy fix as a fallback
// maybe i'll improve this later, but probably not.
+ ((file.sizeInBytes||0) >= 26214400 ? `
<meta property="og:video:width" content="1280">
<meta property="og:video:height" content="720">` : "")
)
: ""
)
)
+ (
fileOwner?.embed?.largeImage && file.visibility!="anonymous"
? `<meta name="twitter:card" content="summary_large_image">`
: ""
)
+ `\n<meta name="theme-color" content="${fileOwner?.embed?.color && file.visibility!="anonymous" && (req.headers["user-agent"]||"").includes("Discordbot") ? `#${fileOwner.embed.color}` : "rgb(30, 33, 36)"}">`
)
.replace(/\<\!\-\-preview\-\-\>/g,
file.mime.startsWith("image/")
? `<div style="min-height:10px"></div><img src="/file/${req.params.fileId}" />`
: (
file.mime.startsWith("video/")
? `<div style="min-height:10px"></div><video src="/file/${req.params.fileId}" controls></video>`
: (
file.mime.startsWith("audio/")
? `<div style="min-height:10px"></div><audio src="/file/${req.params.fileId}" controls></audio>`
: ""
)
)
)
.replace(/\$Uploader/g,!file.owner||file.visibility=="anonymous" ? "Anonymous" : `@${fileOwner?.username || "Deleted User"}`)
)
})
} else {
ServeError(res,404,"file not found")
}
})
/*
routes should be in this order:
index
api
dl pages
file serving
*/
// listen on 3000 or MONOFILE_PORT
app.listen(process.env.MONOFILE_PORT || 3000,function() {
console.log("Web OK!")
})
client.login(process.env.TOKEN)

132
src/server/lib/accounts.ts Normal file
View file

@ -0,0 +1,132 @@
import crypto from "crypto"
import * as auth from "./auth";
import { readFile, writeFile } from "fs/promises"
import { FileVisibility } from "./files";
// this is probably horrible
// but i don't even care anymore
export let Accounts: Account[] = []
export interface Account {
id : string
username : string
email? : string
password : {
hash : string
salt : string
}
files : string[]
admin : boolean
defaultFileVisibility : FileVisibility
customCSS? : string
embed? : {
color? : string
largeImage? : boolean
}
}
export function create(username:string,pwd:string,admin:boolean=false):Promise<string> {
return new Promise((resolve,reject) => {
let accId = crypto.randomBytes(12).toString("hex")
Accounts.push(
{
id: accId,
username: username,
password: password.hash(pwd),
files: [],
admin: admin,
defaultFileVisibility: "public"
}
)
save().then(() => resolve(accId))
})
}
export function getFromUsername(username:string) {
return Accounts.find(e => e.username == username)
}
export function getFromId(id:string) {
return Accounts.find(e => e.id == id)
}
export function getFromToken(token:string) {
let accId = auth.validate(token)
if (!accId) return
return getFromId(accId)
}
export function deleteAccount(id:string) {
Accounts.splice(Accounts.findIndex(e => e.id == id),1)
return save()
}
export namespace password {
export function hash(password:string,_salt?:string) {
let salt = _salt || crypto.randomBytes(12).toString('base64')
let hash = crypto.createHash('sha256').update(`${salt}${password}`).digest('hex')
return {
salt:salt,
hash:hash
}
}
export function set(id:string,password:string) {
let acc = Accounts.find(e => e.id == id)
if (!acc) return
acc.password = hash(password)
return save()
}
export function check(id:string,password:string) {
let acc = Accounts.find(e => e.id == id)
if (!acc) return
return acc.password.hash == hash(password,acc.password.salt).hash
}
}
export namespace files {
export function index(accountId:string,fileId:string) {
// maybe replace with a obj like
// { x:true }
// for faster lookups? not sure if it would be faster
let acc = Accounts.find(e => e.id == accountId)
if (!acc) return
if (acc.files.find(e => e == fileId)) return
acc.files.push(fileId)
return save()
}
export function deindex(accountId:string,fileId:string, noWrite:boolean=false) {
let acc = Accounts.find(e => e.id == accountId)
if (!acc) return
let fi = acc.files.findIndex(e => e == fileId)
if (fi) {
acc.files.splice(fi,1)
if (!noWrite) return save()
}
}
}
export function save() {
return writeFile(`${process.cwd()}/.data/accounts.json`,JSON.stringify(Accounts))
.catch((err) => console.error(err))
}
readFile(`${process.cwd()}/.data/accounts.json`)
.then((buf) => {
Accounts = JSON.parse(buf.toString())
}).catch(err => console.error(err))
.finally(() => {
if (!Accounts.find(e => e.admin)) {
create("admin","admin",true)
}
})

58
src/server/lib/auth.ts Normal file
View file

@ -0,0 +1,58 @@
import crypto from "crypto"
import { readFile, writeFile } from "fs/promises"
export let AuthTokens: AuthToken[] = []
export let AuthTokenTO:{[key:string]:NodeJS.Timeout} = {}
export interface AuthToken {
account: string,
token: string,
expire: number
}
export function create(id:string,expire:number=(24*60*60*1000)) {
let token = {
account:id,
token:crypto.randomBytes(12).toString('hex'),
expire:Date.now()+expire
}
AuthTokens.push(token)
tokenTimer(token)
save()
return token.token
}
export function validate(token:string) {
return AuthTokens.find(e => e.token == token && Date.now() < e.expire)?.account
}
export function tokenTimer(token:AuthToken) {
if (Date.now() >= token.expire) {
invalidate(token.token)
return
}
AuthTokenTO[token.token] = setTimeout(() => invalidate(token.token),token.expire-Date.now())
}
export function invalidate(token:string) {
if (AuthTokenTO[token]) {
clearTimeout(AuthTokenTO[token])
}
AuthTokens.splice(AuthTokens.findIndex(e => e.token == token),1)
save()
}
export function save() {
writeFile(`${process.cwd()}/.data/tokens.json`,JSON.stringify(AuthTokens))
.catch((err) => console.error(err))
}
readFile(`${process.cwd()}/.data/tokens.json`)
.then((buf) => {
AuthTokens = JSON.parse(buf.toString())
AuthTokens.forEach(e => tokenTimer(e))
}).catch(err => console.error(err))

37
src/server/lib/errors.ts Normal file
View file

@ -0,0 +1,37 @@
import { Response } from "express";
import { readFile } from "fs/promises"
let errorPage:string
export default async function ServeError(
res:Response,
code:number,
reason:string
) {
// fetch error page if not cached
if (!errorPage) {
errorPage =
(
await readFile(`${process.cwd()}/pages/error.html`)
.catch((err) => console.error(err))
|| "<pre>$code $text</pre>"
)
.toString()
}
// serve error
res.statusMessage = reason
res.status(code)
res.header("x-backup-status-message", reason) // glitch default nginx configuration
res.send(
errorPage
.replace(/\$code/g,code.toString())
.replace(/\$text/g,reason)
)
}
export function Redirect(res:Response,url:string) {
res.status(302)
res.header("Location",url)
res.send()
}

437
src/server/lib/files.ts Normal file
View file

@ -0,0 +1,437 @@
import axios from "axios";
import Discord, { Client, TextBasedChannel } from "discord.js";
import { readFile, writeFile } from "fs";
import { Readable } from "node:stream";
import { files } from "./accounts";
import * as Accounts from "./accounts";
export let id_check_regex = /[A-Za-z0-9_\-\.\!]+/
export let alphanum = Array.from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
// bad solution but whatever
export type FileVisibility = "public" | "anonymous" | "private"
export function generateFileId(length:number=5) {
let fid = ""
for (let i = 0; i < length; i++) {
fid += alphanum[Math.floor(Math.random()*alphanum.length)]
}
return fid
}
export interface FileUploadSettings {
name?: string,
mime: string,
uploadId?: string,
owner?:string
}
export interface Configuration {
maxDiscordFiles: number,
maxDiscordFileSize: number,
targetGuild: string,
targetChannel: string,
requestTimeout: number,
maxUploadIdLength: number,
accounts: {
registrationEnabled: boolean,
requiredForUpload: boolean
}
}
export interface FilePointer {
filename:string,
mime:string,
messageids:string[],
owner?:string,
sizeInBytes?:number,
tag?:string,
visibility?:FileVisibility,
reserved?: boolean,
chunkSize?: number
}
export interface StatusCodeError {
status: number,
message: string
}
/* */
export default class Files {
config: Configuration
client: Client
files: {[key:string]:FilePointer} = {}
uploadChannel?: TextBasedChannel
constructor(client: Client, config: Configuration) {
this.config = config;
this.client = client;
client.on("ready",() => {
console.log("Discord OK!")
client.guilds.fetch(config.targetGuild).then((g) => {
g.channels.fetch(config.targetChannel).then((a) => {
if (a?.isTextBased()) {
this.uploadChannel = a
}
})
})
})
readFile(process.cwd()+"/.data/files.json",(err,buf) => {
if (err) {console.log(err);return}
this.files = JSON.parse(buf.toString() || "{}")
})
}
uploadFile(settings:FileUploadSettings,fBuffer:Buffer):Promise<string|StatusCodeError> {
return new Promise<string>(async (resolve,reject) => {
if (!this.uploadChannel) {
reject({status:503,message:"server is not ready - please try again later"})
return
}
if (!settings.name || !settings.mime) {
reject({status:400,message:"missing name/mime"});
return
}
if (!settings.owner && this.config.accounts.requiredForUpload) {
reject({status:401,message:"an account is required for upload"});
return
}
let uploadId = (settings.uploadId || generateFileId()).toString();
if ((uploadId.match(id_check_regex) || [])[0] != uploadId || uploadId.length > this.config.maxUploadIdLength) {
reject({status:400,message:"invalid id"});return
}
if (this.files[uploadId] && (settings.owner ? this.files[uploadId].owner != settings.owner : true)) {
reject({status:400,message:"you are not the owner of this file id"});
return
}
if (this.files[uploadId] && this.files[uploadId].reserved) {
reject({status:400,message:"already uploading this file. if your file is stuck in this state, contact an administrator"});
return
}
if (settings.name.length > 128) {
reject({status:400,message:"name too long"});
return
}
if (settings.mime.length > 128) {
reject({status:400,message:"mime too long"});
return
}
// reserve file, hopefully should prevent
// large files breaking
let ogf = this.files[uploadId]
this.files[uploadId] = {
filename:settings.name,
messageids:[],
mime:settings.mime,
sizeInBytes:0,
owner:settings.owner,
visibility: settings.owner ? "private" : "public",
reserved: true,
chunkSize: this.config.maxDiscordFileSize
}
// save
if (settings.owner) {
await files.index(settings.owner,uploadId)
}
// get buffer
if (fBuffer.byteLength >= (this.config.maxDiscordFileSize*this.config.maxDiscordFiles)) {
reject({status:400,message:"file too large"});
return
}
// generate buffers to upload
let toUpload = []
for (let i = 0; i < Math.ceil(fBuffer.byteLength/this.config.maxDiscordFileSize); i++) {
toUpload.push(
fBuffer.subarray(
i*this.config.maxDiscordFileSize,
Math.min(
fBuffer.byteLength,
(i+1)*this.config.maxDiscordFileSize
)
)
)
}
// begin uploading
let uploadTmplt:Discord.AttachmentBuilder[] = toUpload.map((e) => {
return new Discord.AttachmentBuilder(e)
.setName(Math.random().toString().slice(2))
})
let uploadGroups = []
for (let i = 0; i < Math.ceil(uploadTmplt.length/10); i++) {
uploadGroups.push(uploadTmplt.slice(i*10,((i+1)*10)))
}
let msgIds = []
for (let i = 0; i < uploadGroups.length; i++) {
let ms = await this.uploadChannel.send({
files:uploadGroups[i]
}).catch((e) => {console.error(e)})
if (ms) {
msgIds.push(ms.id)
} else {
if (!ogf) delete this.files[uploadId]
else this.files[uploadId] = ogf
reject({status:500,message:"please try again"}); return
}
}
// this code deletes the files from discord, btw
// if need be, replace with job queue system
if (ogf&&this.uploadChannel) {
for (let x of ogf.messageids) {
this.uploadChannel.messages.delete(x).catch(err => console.error(err))
}
}
resolve(await this.writeFile(
uploadId,
{
filename:settings.name,
messageids:msgIds,
mime:settings.mime,
sizeInBytes:fBuffer.byteLength,
owner:settings.owner,
visibility: ogf ? ogf.visibility
: (
settings.owner
? Accounts.getFromId(settings.owner)?.defaultFileVisibility
: undefined
),
// so that json.stringify doesnt include tag:undefined
...((ogf||{}).tag ? {tag:ogf.tag} : {}),
chunkSize: this.config.maxDiscordFileSize
}
))
})
}
// fs
writeFile(uploadId: string, file: FilePointer):Promise<string> {
return new Promise((resolve, reject) => {
this.files[uploadId] = file
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
if (err) {
reject({status:500,message:"server may be misconfigured, contact admin for help"});
delete this.files[uploadId];
return
}
resolve(uploadId)
})
})
}
// todo: move read code here
readFileStream(uploadId: string, range?: {start:number, end:number}):Promise<Readable> {
return new Promise(async (resolve,reject) => {
if (!this.uploadChannel) {
reject({status:503,message:"server is not ready - please try again later"})
return
}
if (this.files[uploadId]) {
let file = this.files[uploadId]
let
scan_msg_begin = 0,
scan_msg_end = file.messageids.length-1,
scan_files_begin = 0,
scan_files_end = -1
let useRanges = range && file.chunkSize && file.sizeInBytes;
// todo: figure out how to get typesccript to accept useRanges
// i'm too tired to look it up or write whatever it wnats me to do
if (range && file.chunkSize && file.sizeInBytes) {
// Calculate where to start file scans...
scan_files_begin = Math.floor(range.start / file.chunkSize)
scan_files_end = Math.ceil(range.end / file.chunkSize) - 1
scan_msg_begin = Math.floor(scan_files_begin / 10)
scan_msg_end = Math.ceil(scan_files_end / 10)
}
let attachments: Discord.Attachment[] = [];
/* File updates */
let file_updates: Pick<FilePointer, "chunkSize" | "sizeInBytes"> = {}
let atSIB: number[] = [] // kepes track of the size of each file...
for (let xi = scan_msg_begin; xi < scan_msg_end+1; xi++) {
let msg = await this.uploadChannel.messages.fetch(file.messageids[xi]).catch(() => {return null})
if (msg?.attachments) {
let attach = Array.from(msg.attachments.values())
for (let i = (useRanges && xi == scan_msg_begin ? ( scan_files_begin - (xi*10) ) : 0); i < (useRanges && xi == scan_msg_end ? ( scan_files_end - (xi*10) + 1 ) : attach.length); i++) {
attachments.push(attach[i])
atSIB.push(attach[i].size)
}
}
}
if (!file.sizeInBytes) file_updates.sizeInBytes = atSIB.reduce((a,b) => a+b);
if (!file.chunkSize) file_updates.chunkSize = atSIB[0]
if (Object.keys(file_updates).length) { // if file_updates not empty
// i gotta do these weird workarounds, ts is weird sometimes
// originally i was gonna do key is keyof FilePointer but for some reason
// it ended up making typeof file[key] never??? so
// its 10pm and chinese people suck at being quiet so i just wanna get this over with
// chinese is the worst language in terms of volume lmao
let valid_fp_keys = ["sizeInBytes", "chunkSize"]
let isValidFilePointerKey = (key: string): key is "sizeInBytes" | "chunkSize" => valid_fp_keys.includes(key)
for (let [key,value] of Object.entries(file_updates)) {
if (isValidFilePointerKey(key)) file[key] = value
}
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {})
}
let position = 0;
let getNextChunk = async () => {
let scanning_chunk = attachments[position]
if (!scanning_chunk) {
return null
}
let d = await axios.get(
scanning_chunk.url,
{
responseType:"arraybuffer",
headers: {
...(useRanges ? {
"Range": `bytes=${position == 0 && range && file.chunkSize ? range.start-(scan_files_begin*file.chunkSize) : "0"}-${position == attachments.length-1 && range && file.chunkSize ? range.end-(scan_files_end*file.chunkSize) : ""}`
} : {})
}
}
).catch((e:Error) => {console.error(e)})
position++;
if (d) {
return d.data
} else {
reject({status:500,message:"internal server error"})
return "__ERR"
}
}
let ord:number[] = []
// hopefully this regulates it?
let lastChunkSent = true
let dataStream = new Readable({
read(){
if (!lastChunkSent) return
lastChunkSent = false
getNextChunk().then(async (nextChunk) => {
if (nextChunk == "__ERR") {this.destroy(new Error("file read error")); return}
let response = this.push(nextChunk)
if (!nextChunk) return // EOF
while (response) {
let nextChunk = await getNextChunk()
response = this.push(nextChunk)
if (!nextChunk) return
}
lastChunkSent = true
})
}
})
resolve(dataStream)
} else {
reject({status:404,message:"not found"})
}
})
}
unlink(uploadId:string, noWrite: boolean = false):Promise<void> {
return new Promise(async (resolve,reject) => {
let tmp = this.files[uploadId];
if (!tmp) {resolve(); return}
if (tmp.owner) {
let id = files.deindex(tmp.owner,uploadId,noWrite);
if (id) await id
}
// this code deletes the files from discord, btw
// if need be, replace with job queue system
if (!this.uploadChannel) {reject(); return}
for (let x of tmp.messageids) {
this.uploadChannel.messages.delete(x).catch(err => console.error(err))
}
delete this.files[uploadId];
if (noWrite) {resolve(); return}
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(this.files),(err) => {
if (err) {
this.files[uploadId] = tmp // !! this may not work, since tmp is a link to this.files[uploadId]?
reject()
} else {
resolve()
}
})
})
}
getFilePointer(uploadId:string):FilePointer {
return this.files[uploadId]
}
}

38
src/server/lib/mail.ts Normal file
View file

@ -0,0 +1,38 @@
import { createTransport } from "nodemailer";
// required i guess
require("dotenv").config()
let
mailConfig =
require( process.cwd() + "/config.json" ).mail,
transport =
createTransport(
{
...mailConfig.transport,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS
}
}
)
// lazy but
export function sendMail(to: string, subject: string, content: string) {
return new Promise((resolve,reject) => {
transport.sendMail({
to,
subject,
"from": mailConfig.send.from,
"html": `<span style="font-size:x-large;font-weight:600;">monofile <span style="opacity:0.5">accounts</span></span><br><span style="opacity:0.5">Gain control of your uploads.</span><hr><br>${
content
.replace(/\<span username\>/g, `<span code><span style="color:#DDAA66;padding-right:3px;">@</span>`)
.replace(/\<span code\>/g,`<span style="font-family:monospace;padding:3px 5px 3px 5px;border-radius:8px;background-color:#1C1C1C;color:#DDDDDD;">`)
}<br><br><span style="opacity:0.5">If you do not believe that you are the intended recipient of this email, please disregard this message.</span>`
}, (err, info) => {
if (err) reject(err)
else resolve(info)
})
})
}

View file

@ -0,0 +1,24 @@
import * as Accounts from "./accounts";
import express, { type RequestHandler } from "express"
import ServeError from "../lib/errors";
export let getAccount: RequestHandler = function(req, res, next) {
res.locals.acc = Accounts.getFromToken(req.cookies.auth)
next()
}
export let requiresAccount: RequestHandler = function(_req, res, next) {
if (!res.locals.acc) {
ServeError(res, 401, "not logged in")
return
}
next()
}
export let requiresAdmin: RequestHandler = function(_req, res, next) {
if (!res.locals.acc.admin) {
ServeError(res, 403, "you are not an administrator")
return
}
next()
}

View file

@ -0,0 +1,45 @@
import { RequestHandler } from "express"
import { type Account } from "./accounts"
import ServeError from "./errors"
interface ratelimitSettings {
requests: number
per: number
}
export function accountRatelimit( settings: ratelimitSettings ): RequestHandler {
let activeLimits: {
[ key: string ]: {
requests: number,
expirationHold: NodeJS.Timeout
}
} = {}
return (req, res, next) => {
if (res.locals.acc) {
let accId = res.locals.acc.id
let aL = activeLimits[accId]
if (!aL) {
activeLimits[accId] = {
requests: 0,
expirationHold: setTimeout(() => delete activeLimits[accId], settings.per)
}
aL = activeLimits[accId]
}
if (aL.requests < settings.requests) {
res.locals.undoCount = () => {
if (activeLimits[accId]) {
activeLimits[accId].requests--
}
}
next()
} else {
ServeError(res, 429, "too many requests")
}
}
}
}

View file

@ -0,0 +1,195 @@
import bodyParser from "body-parser";
import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import bytes from "bytes"
import {writeFile} from "fs";
import { sendMail } from "../lib/mail";
import { getAccount, requiresAccount, requiresAdmin } from "../lib/middleware"
import ServeError from "../lib/errors";
import Files from "../lib/files";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let adminRoutes = Router();
adminRoutes
.use(getAccount)
.use(requiresAccount)
.use(requiresAdmin)
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
let config = require(`${process.cwd()}/config.json`)
adminRoutes.post("/reset", parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string" || typeof req.body.password !== "string") {
res.status(404)
res.send()
return
}
let targetAccount = Accounts.getFromUsername(req.body.target)
if (!targetAccount) {
res.status(404)
res.send()
return
}
Accounts.password.set ( targetAccount.id, req.body.password )
auth.AuthTokens.filter(e => e.account == targetAccount?.id).forEach((v) => {
auth.invalidate(v.token)
})
if (targetAccount.email) {
sendMail(targetAccount.email, `Your login details have been updated`, `<b>Hello there!</b> This email is to notify you of a password change that an administrator, <span username>${acc.username}</span>, has initiated. You have been logged out of your devices. Thank you for using monofile.`).then(() => {
res.send("OK")
}).catch((err) => {})
}
res.send()
})
adminRoutes.post("/elevate", parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string") {
res.status(404)
res.send()
return
}
let targetAccount = Accounts.getFromUsername(req.body.target)
if (!targetAccount) {
res.status(404)
res.send()
return
}
targetAccount.admin = true;
Accounts.save()
res.send()
})
adminRoutes.post("/delete", parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string") {
res.status(404)
res.send()
return
}
let targetFile = files.getFilePointer(req.body.target)
if (!targetFile) {
res.status(404)
res.send()
return
}
files.unlink(req.body.target).then(() => {
res.status(200)
}).catch(() => {
res.status(500)
}).finally(() => res.send())
})
adminRoutes.post("/delete_account", parser, async (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string") {
res.status(404)
res.send()
return
}
let targetAccount = Accounts.getFromUsername(req.body.target)
if (!targetAccount) {
res.status(404)
res.send()
return
}
let accId = targetAccount.id
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
let cpl = () => Accounts.deleteAccount(accId).then(_ => {
if (targetAccount?.email) {
sendMail(targetAccount.email, "Notice of account deletion", `Your account, <span username>${targetAccount.username}</span>, has been deleted by <span username>${acc.username}</span> for the following reason: <br><br><span style="font-weight:600">${req.body.reason || "(no reason specified)"}</span><br><br> Your files ${req.body.deleteFiles ? "have been deleted" : "have not been modified"}. Thank you for using monofile.`)
}
res.send("account deleted")
})
if (req.body.deleteFiles) {
let f = targetAccount.files.map(e=>e) // make shallow copy so that iterating over it doesnt Die
for (let v of f) {
files.unlink(v,true).catch(err => console.error(err))
}
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
if (err) console.log(err)
cpl()
})
} else cpl()
})
adminRoutes.post("/transfer", parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.target !== "string" || typeof req.body.owner !== "string") {
res.status(404)
res.send()
return
}
let targetFile = files.getFilePointer(req.body.target)
if (!targetFile) {
res.status(404)
res.send()
return
}
let newOwner = Accounts.getFromUsername(req.body.owner || "")
// clear old owner
if (targetFile.owner) {
let oldOwner = Accounts.getFromId(targetFile.owner)
if (oldOwner) {
Accounts.files.deindex(oldOwner.id, req.body.target)
}
}
if (newOwner) {
Accounts.files.index(newOwner.id, req.body.target)
}
targetFile.owner = newOwner ? newOwner.id : undefined;
files.writeFile(req.body.target, targetFile).then(() => {
res.send()
}).catch(() => {
res.status(500)
res.send()
}) // wasting a reassignment but whatee
})

View file

@ -0,0 +1,461 @@
import bodyParser from "body-parser";
import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import { sendMail } from "../lib/mail";
import { getAccount, requiresAccount } from "../lib/middleware"
import { accountRatelimit } from "../lib/ratelimit"
import ServeError from "../lib/errors";
import Files, { FileVisibility, generateFileId, id_check_regex } from "../lib/files";
import { writeFile } from "fs";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let authRoutes = Router();
authRoutes.use(getAccount)
let config = require(`${process.cwd()}/config.json`)
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
authRoutes.post("/login", parser, (req,res) => {
if (typeof req.body.username != "string" || typeof req.body.password != "string") {
ServeError(res,400,"please provide a username or password")
return
}
if (auth.validate(req.cookies.auth)) return
/*
check if account exists
*/
let acc = Accounts.getFromUsername(req.body.username)
if (!acc) {
ServeError(res,401,"username or password incorrect")
return
}
if (!Accounts.password.check(acc.id,req.body.password)) {
ServeError(res,401,"username or password incorrect")
return
}
/*
assign token
*/
res.cookie("auth",auth.create(acc.id,(3*24*60*60*1000)))
res.status(200)
res.end()
})
authRoutes.post("/create", parser, (req,res) => {
if (!config.accounts.registrationEnabled) {
ServeError(res,403,"account registration disabled")
return
}
if (auth.validate(req.cookies.auth)) return
if (typeof req.body.username != "string" || typeof req.body.password != "string") {
ServeError(res,400,"please provide a username or password")
return
}
/*
check if account exists
*/
let acc = Accounts.getFromUsername(req.body.username)
if (acc) {
ServeError(res,400,"account with this username already exists")
return
}
if (req.body.username.length < 3 || req.body.username.length > 20) {
ServeError(res,400,"username must be over or equal to 3 characters or under or equal to 20 characters in length")
return
}
if ((req.body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != req.body.username) {
ServeError(res,400,"username contains invalid characters")
return
}
if (req.body.password.length < 8) {
ServeError(res,400,"password must be 8 characters or longer")
return
}
Accounts.create(req.body.username,req.body.password)
.then((newAcc) => {
/*
assign token
*/
res.cookie("auth",auth.create(newAcc,(3*24*60*60*1000)))
res.status(200)
res.end()
})
.catch(() => {
ServeError(res,500,"internal server error")
})
})
authRoutes.post("/logout", (req,res) => {
if (!auth.validate(req.cookies.auth)) {
ServeError(res, 401, "not logged in")
return
}
auth.invalidate(req.cookies.auth)
res.send("logged out")
})
authRoutes.post("/dfv", requiresAccount, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (['public','private','anonymous'].includes(req.body.defaultFileVisibility)) {
acc.defaultFileVisibility = req.body.defaultFileVisibility
Accounts.save()
res.send(`dfv has been set to ${acc.defaultFileVisibility}`)
} else {
res.status(400)
res.send("invalid dfv")
}
})
authRoutes.post("/customcss", requiresAccount, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.fileId != "string") req.body.fileId = undefined;
if (
!req.body.fileId
|| (req.body.fileId.match(id_check_regex) == req.body.fileId
&& req.body.fileId.length <= config.maxUploadIdLength)
) {
acc.customCSS = req.body.fileId || undefined
if (!req.body.fileId) delete acc.customCSS
Accounts.save()
res.send(`custom css saved`)
} else {
res.status(400)
res.send("invalid fileid")
}
})
authRoutes.post("/embedcolor", requiresAccount, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.color != "string") req.body.color = undefined;
if (
!req.body.color
|| (req.body.color.toLowerCase().match(/[a-f0-9]+/) == req.body.color)
&& req.body.color.length == 6
) {
if (!acc.embed) acc.embed = {}
acc.embed.color = req.body.color || undefined
if (!req.body.color) delete acc.embed.color
Accounts.save()
res.send(`custom embed color saved`)
} else {
res.status(400)
res.send("invalid hex code")
}
})
authRoutes.post("/embedsize", requiresAccount, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.largeImage != "boolean") req.body.color = false;
if (!acc.embed) acc.embed = {}
acc.embed.largeImage = req.body.largeImage
if (!req.body.largeImage) delete acc.embed.largeImage
Accounts.save()
res.send(`custom embed image size saved`)
})
authRoutes.post("/delete_account", requiresAccount, parser, async (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
let cpl = () => Accounts.deleteAccount(accId).then(_ => res.send("account deleted"))
if (req.body.deleteFiles) {
let f = acc.files.map(e=>e) // make shallow copy so that iterating over it doesnt Die
for (let v of f) {
files.unlink(v,true).catch(err => console.error(err))
}
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
if (err) console.log(err)
cpl()
})
} else cpl()
})
authRoutes.post("/change_username", requiresAccount, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.username != "string" || req.body.username.length < 3 || req.body.username.length > 20) {
ServeError(res,400,"username must be between 3 and 20 characters in length")
return
}
let _acc = Accounts.getFromUsername(req.body.username)
if (_acc) {
ServeError(res,400,"account with this username already exists")
return
}
if ((req.body.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != req.body.username) {
ServeError(res,400,"username contains invalid characters")
return
}
acc.username = req.body.username
Accounts.save()
if (acc.email) {
sendMail(acc.email, `Your login details have been updated`, `<b>Hello there!</b> Your username has been updated to <span username>${req.body.username}</span>. Please update your devices accordingly. Thank you for using monofile.`).then(() => {
res.send("OK")
}).catch((err) => {})
}
res.send("username changed")
})
// shit way to do this but...
let verificationCodes = new Map<string, {code: string, email: string, expiry: NodeJS.Timeout}>()
authRoutes.post("/request_email_change", requiresAccount, accountRatelimit({ requests: 4, per: 60*60*1000 }), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.email != "string" || !req.body.email) {
ServeError(res,400, "supply an email")
return
}
let vcode = verificationCodes.get(acc.id)
// delete previous if any
let e = vcode?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id||"")
let code = generateFileId(12).toUpperCase()
// set
verificationCodes.set(acc.id, {
code,
email: req.body.email,
expiry: setTimeout( () => verificationCodes.delete(acc?.id||""), 15*60*1000)
})
// this is a mess but it's fine
sendMail(req.body.email, `Hey there, ${acc.username} - let's connect your email`, `<b>Hello there!</b> You are recieving this message because you decided to link your email, <span code>${req.body.email.split("@")[0]}<span style="opacity:0.5">@${req.body.email.split("@")[1]}</span></span>, to your account, <span username>${acc.username}</span>. If you would like to continue, please <a href="https://${req.header("Host")}/auth/confirm_email/${code}"><span code>click here</span></a>, or go to https://${req.header("Host")}/auth/confirm_email/${code}.`).then(() => {
res.send("OK")
}).catch((err) => {
let e = verificationCodes.get(acc?.id||"")?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id||"")
res.locals.undoCount();
ServeError(res, 500, err?.toString())
})
})
authRoutes.get("/confirm_email/:code", requiresAccount, (req,res) => {
let acc = res.locals.acc as Accounts.Account
let vcode = verificationCodes.get(acc.id)
if (!vcode) { ServeError(res, 400, "nothing to confirm"); return }
if (typeof req.params.code == "string" && req.params.code.toUpperCase() == vcode.code) {
acc.email = vcode.email
Accounts.save();
let e = verificationCodes.get(acc?.id||"")?.expiry
if (e) clearTimeout(e)
verificationCodes.delete(acc?.id||"")
res.redirect("/")
} else {
ServeError(res, 400, "invalid code")
}
})
let pwReset = new Map<string, {code: string, expiry: NodeJS.Timeout, requestedAt:number}>()
let prcIdx = new Map<string, string>()
authRoutes.post("/request_emergency_login", parser, (req,res) => {
if (auth.validate(req.cookies.auth || "")) return
if (typeof req.body.account != "string" || !req.body.account) {
ServeError(res,400, "supply a username")
return
}
let acc = Accounts.getFromUsername(req.body.account)
if (!acc || !acc.email) {
ServeError(res, 400, "this account either does not exist or does not have an email attached; please contact the server's admin for a reset if you would still like to access it")
return
}
let pResetCode = pwReset.get(acc.id)
if (pResetCode && pResetCode.requestedAt+(15*60*1000) > Date.now()) {
ServeError(res, 429, `Please wait a few moments to request another emergency login.`)
return
}
// delete previous if any
let e = pResetCode?.expiry
if (e) clearTimeout(e)
pwReset.delete(acc?.id||"")
prcIdx.delete(pResetCode?.code||"")
let code = generateFileId(12).toUpperCase()
// set
pwReset.set(acc.id, {
code,
expiry: setTimeout( () => { pwReset.delete(acc?.id||""); prcIdx.delete(pResetCode?.code||"") }, 15*60*1000),
requestedAt: Date.now()
})
prcIdx.set(code, acc.id)
// this is a mess but it's fine
sendMail(acc.email, `Emergency login requested for ${acc.username}`, `<b>Hello there!</b> You are recieving this message because you forgot your password to your monofile account, <span username>${acc.username}</span>. To log in, please <a href="https://${req.header("Host")}/auth/emergency_login/${code}"><span code>click here</span></a>, or go to https://${req.header("Host")}/auth/emergency_login/${code}. If it doesn't appear that you are logged in after visiting this link, please try refreshing. Once you have successfully logged in, you may reset your password.`).then(() => {
res.send("OK")
}).catch((err) => {
let e = pwReset.get(acc?.id||"")?.expiry
if (e) clearTimeout(e)
pwReset.delete(acc?.id||"")
prcIdx.delete(code||"")
ServeError(res, 500, err?.toString())
})
})
authRoutes.get("/emergency_login/:code", (req,res) => {
if (auth.validate(req.cookies.auth || "")) {
ServeError(res, 403, "already logged in")
return
}
let vcode = prcIdx.get(req.params.code)
if (!vcode) { ServeError(res, 400, "invalid emergency login code"); return }
if (typeof req.params.code == "string" && vcode) {
res.cookie("auth",auth.create(vcode,(3*24*60*60*1000)))
res.redirect("/")
let e = pwReset.get(vcode)?.expiry
if (e) clearTimeout(e)
pwReset.delete(vcode)
prcIdx.delete(req.params.code)
} else {
ServeError(res, 400, "invalid code")
}
})
authRoutes.post("/change_password", requiresAccount, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.password != "string" || req.body.password.length < 8) {
ServeError(res,400,"password must be 8 characters or longer")
return
}
let accId = acc.id
Accounts.password.set(accId,req.body.password)
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
if (acc.email) {
sendMail(acc.email, `Your login details have been updated`, `<b>Hello there!</b> This email is to notify you of a password change that you have initiated. You have been logged out of your devices. Thank you for using monofile.`).then(() => {
res.send("OK")
}).catch((err) => {})
}
res.send("password changed - logged out all sessions")
})
authRoutes.post("/logout_sessions", requiresAccount, (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
auth.AuthTokens.filter(e => e.account == accId).forEach((v) => {
auth.invalidate(v.token)
})
res.send("logged out all sessions")
})
authRoutes.get("/me", requiresAccount, (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
res.send({
...acc,
sessionCount: auth.AuthTokens.filter(e => e.account == accId && e.expire > Date.now()).length,
sessionExpires: auth.AuthTokens.find(e => e.token == req.cookies.auth)?.expire,
password: undefined
})
})
authRoutes.get("/customCSS", (req,res) => {
if (!auth.validate(req.cookies.auth)) {
ServeError(res, 401, "not logged in")
return
}
// lazy rn so
let acc = Accounts.getFromToken(req.cookies.auth)
if (acc) {
if (acc.customCSS) {
res.redirect(`/file/${acc.customCSS}`)
} else {
res.send("")
}
} else res.send("")
})

View file

@ -0,0 +1,104 @@
import bodyParser from "body-parser";
import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import bytes from "bytes"
import {writeFile} from "fs";
import ServeError from "../lib/errors";
import Files from "../lib/files";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let fileApiRoutes = Router();
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
let config = require(`${process.cwd()}/config.json`)
fileApiRoutes.get("/list", (req,res) => {
if (!auth.validate(req.cookies.auth)) {
ServeError(res, 401, "not logged in")
return
}
let acc = Accounts.getFromToken(req.cookies.auth)
if (!acc) return
let accId = acc.id
res.send(acc.files.map((e) => {
let fp = files.getFilePointer(e)
if (!fp) { Accounts.files.deindex(accId, e); return null }
return {
...fp,
messageids: null,
owner: null,
id:e
}
}).filter(e=>e))
})
fileApiRoutes.post("/manage", parser, (req,res) => {
if (!auth.validate(req.cookies.auth)) {
ServeError(res, 401, "not logged in")
return
}
let acc = Accounts.getFromToken(req.cookies.auth) as Accounts.Account
if (!acc) return
if (!req.body.target || !(typeof req.body.target == "object") || req.body.target.length < 1) return
let modified = 0
req.body.target.forEach((e:string) => {
if (!acc.files.includes(e)) return
let fp = files.getFilePointer(e)
if (fp.reserved) {
return
}
switch( req.body.action ) {
case "delete":
files.unlink(e, true)
modified++;
break;
case "changeFileVisibility":
if (!["public","anonymous","private"].includes(req.body.value)) return;
files.files[e].visibility = req.body.value;
modified++;
break;
case "setTag":
if (!req.body.value) delete files.files[e].tag
else {
if (req.body.value.toString().length > 30) return
files.files[e].tag = req.body.value.toString().toLowerCase()
}
modified++;
break;
}
})
Accounts.save().then(() => {
writeFile(process.cwd()+"/.data/files.json",JSON.stringify(files.files), (err) => {
if (err) console.log(err)
res.contentType("text/plain")
res.send(`modified ${modified} files`)
})
}).catch((err) => console.error(err))
})

View file

@ -0,0 +1,171 @@
import bodyParser from "body-parser";
import express, { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import axios, { AxiosResponse } from "axios"
import { type Range } from "range-parser";
import multer, {memoryStorage} from "multer"
import ServeError from "../lib/errors";
import Files from "../lib/files";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
})
export let primaryApi = Router();
let files:Files
export function setFilesObj(newFiles:Files) {
files = newFiles
}
const multerSetup = multer({storage:memoryStorage()})
let config = require(`${process.cwd()}/config.json`)
primaryApi.get(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], async (req:express.Request,res:express.Response) => {
let file = files.getFilePointer(req.params.fileId)
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Content-Security-Policy","sandbox allow-scripts")
if (req.query.attachment == "1") res.setHeader("Content-Disposition", "attachment")
if (file) {
if (file.visibility == "private" && Accounts.getFromToken(req.cookies.auth)?.id != file.owner) {
ServeError(res,403,"you do not own this file")
return
}
let range: Range | undefined
res.setHeader("Content-Type",file.mime)
if (file.sizeInBytes) {
res.setHeader("Content-Length",file.sizeInBytes)
if (file.chunkSize) {
let rng = req.range(file.sizeInBytes)
if (rng) {
// error handling
if (typeof rng == "number") {
res.status(rng == -1 ? 416 : 400).send()
return
}
if (rng.type != "bytes") {
res.status(400).send();
return
}
// set ranges var
let rngs = Array.from(rng)
if (rngs.length != 1) { res.status(400).send(); return }
range = rngs[0]
}
}
}
// supports ranges
files.readFileStream(req.params.fileId, range).then(async stream => {
if (range) {
res.status(206)
res.header("Content-Length", (range.end-range.start + 1).toString())
res.header("Content-Range", `bytes ${range.start}-${range.end}/${file.sizeInBytes}`)
}
stream.pipe(res)
}).catch((err) => {
ServeError(res,err.status,err.message)
})
} else {
ServeError(res, 404, "file not found")
}
})
primaryApi.head(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], (req: express.Request, res:express.Response) => {
let file = files.getFilePointer(req.params.fileId)
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Content-Security-Policy","sandbox allow-scripts")
if (req.query.attachment == "1") res.setHeader("Content-Disposition", "attachment")
if (!file) {
res.status(404)
res.send()
} else {
res.setHeader("Content-Type",file.mime)
if (file.sizeInBytes) {
res.setHeader("Content-Length",file.sizeInBytes)
}
if (file.chunkSize) {
res.setHeader("Accept-Ranges", "bytes")
}
}
})
// upload handlers
primaryApi.post("/upload",multerSetup.single('file'),async (req,res) => {
if (req.file) {
try {
let prm = req.header("monofile-params")
let params:{[key:string]:any} = {}
if (prm) {
params = JSON.parse(prm)
}
files.uploadFile({
owner: auth.validate(req.cookies.auth),
uploadId:params.uploadId,
name:req.file.originalname,
mime:req.file.mimetype
},req.file.buffer)
.then((uID) => res.send(uID))
.catch((stat) => {
res.status(stat.status);
res.send(`[err] ${stat.message}`)
})
} catch {
res.status(400)
res.send("[err] bad request")
}
} else {
res.status(400)
res.send("[err] bad request")
}
})
primaryApi.post("/clone", bodyParser.json({type: ["text/plain","application/json"]}) ,(req,res) => {
try {
axios.get(req.body.url,{responseType:"arraybuffer"}).then((data:AxiosResponse) => {
files.uploadFile({
owner: auth.validate(req.cookies.auth),
name:req.body.url.split("/")[req.body.url.split("/").length-1] || "generic",
mime:data.headers["content-type"],
uploadId:req.body.uploadId
},Buffer.from(data.data))
.then((uID) => res.send(uID))
.catch((stat) => {
res.status(stat.status);
res.send(`[err] ${stat.message}`)
})
}).catch((err) => {
console.log(err)
res.status(400)
res.send(`[err] failed to fetch data`)
})
} catch {
res.status(500)
res.send("[err] an error occured")
}
})

85
src/style/_base.scss Normal file
View file

@ -0,0 +1,85 @@
/*
could probably replace this with fonts served directly
from the server but it's fine for now
*/
@import url("/static/assets/fonts/inconsolata.css");
@import url("/static/assets/fonts/source_sans.css");
@import url("/static/assets/fonts/fira_code.css");
$FallbackFonts:
-apple-system,
system-ui,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
%normal {
font-family: "Source Sans Pro", $FallbackFonts
}
/*
everything that's not a span
and/or has the normal class
(it's just in case)
*/
*:not(span), .normal { @extend %normal; }
/*
for code blocks / terminal
*/
.monospace {
font-family: "Fira Code", monospace
}
/*
colors
*/
$Background: #252525;
/* hsl(210,12.9,24.3) */
$darkish: rgb(54, 62, 70);
/*
then other stuff
*/
body {
background-color: rgb(30, 33, 36); // this is here so that
// pulling down to refresh
// on mobile looks good
}
#appContent {
background-color: $Background
}
/*
scrollbars
*/
* {
/* nice scrollbars aren't needed on mobile so */
@media screen and (min-width:500px) {
&::-webkit-scrollbar {
width:5px;
}
&::-webkit-scrollbar-track {
background-color:#191919;
}
&::-webkit-scrollbar-thumb {
background-color:#333;
&:hover {
background-color:#373737;
}
}
}
}

41
src/style/app.scss Normal file
View file

@ -0,0 +1,41 @@
@use "base";
@use "app/topbar";
@use "app/pulldown";
@use "app/uploads";
.menuBtn {
text-decoration:none;
font-size:16px;
transition-duration: 100ms;
color:#555555;
background-color: #00000000;
border:none;
margin:0 0 0 0;
cursor:pointer;
position:relative;
top:-1px;
&:hover {
color:slategray;
transition-duration: 100ms;
}
}
#appContent {
position:absolute;
left:0px;
top:40px;
width:100%;
height: calc( 100% - 40px );
background-image: linear-gradient(#333,base.$Background);
@media screen and (max-width:500px) {
background-image: linear-gradient(#303030,base.$Background);
}
}
.number {
font-family: "Inconsolata", monospace;
}

View file

@ -0,0 +1,49 @@
@use "../base";
@use "pulldown/help";
@use "pulldown/accounts";
@use "pulldown/files";
@use "pulldown/modals";
#overlay, .modalContainer {
position:absolute;
left:0px;
height: 100%;
width:100%;
top:0px;
border:none;
outline:none;
background-color:rgba(170, 170, 170, 0.25);
z-index: 1000;
}
.pulldown {
position: absolute;
width: 300px;
height: 400px;
background-color: #191919;
color: #dddddd;
top:0px;
left:50%;
transform:translateX(-50%);
@media screen and (max-width: 500px) {
width: 100%;
height: 100%;
}
p, h1, h2 {
margin:0px;
}
z-index: 1001;
}
.pulldown_display {
position:absolute;
left:0px;
top:0px;
width:100%;
height:100%;
}

View file

@ -0,0 +1,187 @@
.pulldown_display[name=accounts] {
.notLoggedIn {
.container_div {
position:absolute;
top:50%;
transform:translateY(-50%);
width:100%;
text-align:center;
h1 {
font-weight:600;
font-size:24px;
@media screen and (max-width:500px) {
font-size:30px;
}
}
.flavor {
font-size:14px;
/* good enoough */
@media screen and (max-width:500px) {
font-size:16px;
}
color:#999999;
margin: 0 0 10px 0;
}
button {
cursor:pointer;
background-color:#393939;
color:#DDDDDD;
border:none;
outline:none;
padding:5px;
transition-duration: 250ms;
/*overflow:clip;*/
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
&:hover {
transition-duration: 250ms;
background-color:#434343;
color: #ffffff;
}
flex-basis:50%;
flex-grow:1;
}
button.flavor {
padding: 0;
background: none;
}
input[type=text],input[type=password] {
border:none;
border-radius:0;
width:100%;
padding:5px;
background-color:#333333;
color:#dddddd;
outline:none;
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
}
.pwError {
div {
border:none;
border-radius:0;
width:100%;
padding:5px;
background-color:#663333;
color:#dddddd;
outline:none;
font-size:14px;
text-align:left;
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
}
}
.lgBtnContainer {
display:flex;
position:relative;
left:20px;
width:calc( 100% - 40px );
gap:10px;
overflow:clip;
}
.fields {
display:flex;
flex-direction:column;
position:relative;
left:20px;
width:calc( 100% - 40px );
gap:5px;
overflow:clip;
}
/*
a {
text-decoration: none;
color:#999999;
font-size:14px;
@media screen and (max-width:500px) {
font-size:16px;
}
&::after {
content:"";
font-size:0px;
opacity: 0;
transition-duration:250ms;
}
&:hover {
&::after {
font-size:13px;
opacity: 1;
transition-duration:250ms;
}
}
}
*/
}
}
.loggedIn {
position:absolute;
/*
left:10px;
top:10px;
*/
left:0px;
top:0px;
width:calc( 100% - 20px );
height:calc( 100% - 20px );
padding:10px;
overflow-y:auto;
h1 {
font-weight:600;
font-size:20px;
color: #AAAAAA;
@media screen and (max-width:500px) {
font-size:24px;
}
.monospace {
font-size:18px;
@media screen and (max-width:500px) {
font-size:22px;
}
}
}
.category {
p {
text-align:left;
}
}
}
}

View file

@ -0,0 +1,160 @@
.pulldown_display[name=files] {
.notLoggedIn {
position:absolute;
top:50%;
left:0px;
transform:translateY(-50%);
width:100%;
text-align:center;
.flavor {
font-size:16px;
color:#999999;
margin: 0 0 10px 0;
}
button {
--col: #999999;
background-color: #232323;
color:var(--col);
font-size:14px;
border:1px solid var(--col);
padding:2px 20px 2px 20px;
cursor:pointer;
transition-duration:250ms;
&:hover {
background-color:#333333;
transition-duration:250ms;
--col:#BBBBBB;
}
}
}
.loggedIn {
display: flex;
flex-direction: column;
max-height:100%;
overflow:hidden;
.searchBar {
transition-duration:150ms;
background-color:#171717;
width:100%;
padding:8px;
color:#dddddd;
border:none;
border-bottom: 1px solid #aaaaaa;
outline: none;
border-radius:0px;
font-size:14px;
&:focus {
transition-duration:150ms;
border-bottom: 1px solid #dddddd;
}
@media screen and (max-width: 500px) {
padding:12px;
font-size:16px;
}
}
.fileList {
overflow-y:auto;
overflow-x:hidden;
padding:5px 0;
.flFile {
padding: 3px 8px;
position:relative;
@media screen and (max-width: 500px) {
padding:7px 12px;
}
.detail {
color:#777777;
font-size:14px;
position:relative;
@media screen and (max-width: 500px) {
font-size:16px;
}
img {
width: 14px;
height: 14px;
/* this is shit but it's the best way i can think of to do this */
/* other than flexbox but i don't feel like doing that rn */
position:relative;
top:2px;
}
}
h2 {
font-size:18px;
text-overflow:ellipsis;
overflow:hidden;
font-weight:600;
@media screen and (max-width: 500px) {
font-size:20px;
}
}
p, h2 {
margin:0 0 0 0;
white-space: nowrap;
}
button {
background-color:#00000000;
border:none;
outline:none;
cursor:pointer;
&.hitbox {
position:absolute;
left:0px;
top:0px;
height:100%;
width:100%;
z-index:10;
}
&.more {
min-height:100%;
width:auto;
aspect-ratio: 1 / 1;
z-index:11;
position:relative;
img {
margin:auto;
}
}
}
.flexCont {
display: flex;
.fileInfo {
width:100%;
min-width:0;
}
}
@media screen and (min-width:500px) {
&:hover {
background-color: #252525;
}
}
}
}
}
}

View file

@ -0,0 +1,22 @@
.pulldown_display[name=help] {
overflow-y:auto;
.faqGroup {
padding:6px 10px 4px 10px;
h2 {
font-weight: 400;
color:#DDDDDD;
font-size:16px;
margin:0 0 0 0;
}
p {
color:#999999;
font-size:16px;
margin:0 0 0 0;
}
}
}

View file

@ -0,0 +1,115 @@
.optPicker {
button, .inp {
position:relative;
width:100%;
height:50px;
background-color: #191919;
border:none;
border-bottom:1px solid #AAAAAA;
transition-duration:150ms;
img {
position:absolute;
left:13px;
top:50%;
transform:translateY(-50%);
}
p,input {
text-align:left;
position:absolute;
top:50%;
left:50px;
color:#DDDDDD;
transform:translateY(-50%);
font-size:14px;
background-color:#00000000;
border:none;
span {
color:#777777;
font-size:12px;
}
}
input {
height:100%;
width:calc(100% - 50px);
outline:none; /* bad idea but i don't even care anymore */
margin:0px;
padding:0px;
}
@media screen and (max-width:500px) {
height:70px;
p,input {
font-size:16px;
left:70px;
span {
font-size:14px;
}
}
input {
width:calc( 100% - 70px );
}
img {
width:30px;
height:30px;
left:20px;
}
}
}
button {
cursor:pointer;
&:hover {
transition-duration:150ms;
background-color: #252525;
}
}
.category {
border-bottom: 1px solid #AAAAAA;
p {
color: #AAAAAA;
font-size: 14px;
margin: 10px 0px 3px 0px;
text-align:center;
@media screen and (max-width:500px) {
font-size:16px;
}
}
}
}
.mdHitbox {
position:absolute;
width:100%;
height:100%;
top:0%;
left:0%;
cursor:pointer;
z-index: 0;
border:none;
background-color: #00000000;
outline:none;
}
.modal {
position:absolute;
background-color:#191919;
width:100%;
transform:translateY(-100%);
top:100%;
left:0%;
z-index: 1;
}

21
src/style/app/topbar.scss Normal file
View file

@ -0,0 +1,21 @@
@use "../base";
#topbar {
position:absolute;
left:0px;
top:0px;
width:100%;
height:40px;
/* hsl(210,9.1,12.9) */
background-color: rgb(30, 33, 36);
display:flex;
flex-direction: row;
justify-content: center;
align-items: center;
column-gap:5px;
}

View file

@ -0,0 +1,115 @@
#uploadWindow {
#add_new_files {
background-color:#191919;
border: 1px solid gray;
padding: 0px 0px 10px 0px;
p {
font-family: "Fira Code", monospace;
text-align: left;
margin: 0px 0px 0px 10px;
font-size: 30px;
span {
position:relative;
&._add_files_txt {
font-size:16px;
top:-4px;
left:10px;
@media screen and (max-width:500px) {
font-size:20px;
top:-6px;
left:10px;
}
}
}
@media screen and (max-width:500px) {
font-size: 40px;
span._add_files_txt {
font-size:20px;
top:-6px;
left:10px;
}
}
}
#file_add_btns {
width:calc( 100% - 20px );
margin:auto;
position:relative;
display:flex;
flex-direction:row;
column-gap:10px;
button, input[type=text] {
background-color:#333333;
color:#DDDDDD;
border:none;
border-radius: 0px;
outline:none;
padding:5px;
flex-basis:50%;
flex-grow:1;
transition-duration:250ms;
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
}
button {
cursor:pointer;
&:hover {
@media screen and (min-width: 500px) {
transition-duration:250ms;
flex-basis: 60%;
}
background-color:#393939;
color: #ffffff;
}
}
.fileUpload {
width:100%;
height:100px;
position:relative;
background-color:#262626;
transition-duration:250ms;
input[type=file] {
opacity: 0;
position:absolute;
left:0px;
top:0px;
width:100%;
height:100%;
cursor:pointer;
}
p {
position:absolute;
top:50%;
transform:translateY(-50%);
font-size:12px;
width:100%;
text-align:center;
padding:0px;
margin: 0px;
}
&:hover {
transition-duration:250ms;
background-color:#292929;
}
}
}
}
}

View file

@ -0,0 +1,59 @@
// should probably start using mixins for thingss like this
#uploadWindow {
.file {
background-color:#191919;
border: 1px solid gray;
padding: 10px;
overflow:clip;
position:relative;
h2 {
font-size: 16px;
margin: 0px;
font-weight:600;
width:calc( 100% - 20px );
}
input[type=text] {
background-color:#333333;
color:#DDDDDD;
border:none;
outline:none;
padding:5px;
position:relative;
width:100%;
transition-duration:250ms;
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
}
.buttonContainer {
display:flex;
column-gap:10px;
button {
flex-basis: 50%;
flex-grow: 1;
padding:5px;
}
}
.uploadingContainer {
color: #AAAAAA;
}
.hitbox {
opacity:0;
position:absolute;
left:0px;
top:0px;
height:100%;
width:100%;
}
}
}

View file

@ -0,0 +1,72 @@
@use "uploader/add_new_files";
@use "uploader/file";
#uploadWindow {
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
padding:10px 15px 10px 15px;
display:flex;
flex-direction: column;
width:350px;
@media screen and (min-width:500px) {
max-height: calc( 100% - 80px );
}
background-color:#222222;
color:#ddd;
h1, p, a {
margin: 0px;
font-size: 14px;
}
a {
color:#999;
}
h1 {
font-weight:600;
font-size: 25px;
}
.uploadContainer {
overflow:auto;
}
button {
cursor:pointer;
background-color:#393939;
color:#DDDDDD;
border:none;
outline:none;
padding:5px;
transition-duration: 250ms;
/*overflow:clip;*/
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
&:hover {
transition-duration: 250ms;
background-color:#434343;
color: #ffffff;
}
}
@media screen and (max-width: 500px) {
width: calc( 100% - 20px );
height: calc( 100% - 20px );
border-radius:0px;
background-color:#00000000;
transform:none;
left:10px;
top:10px;
padding:0px;
}
}

26
src/style/downloads.scss Normal file
View file

@ -0,0 +1,26 @@
// probably dont need to import the entire
// uploads css file
// so i might just make a separate file with mixins
// and import them
@use "app/uploads";
@use "base";
#appContent {
position:absolute;
left:0px;
top:0px;
width:100%;
height:100%;
background-image: linear-gradient(#333,base.$Background);
@media screen and (max-width:500px) {
background-image: linear-gradient(#303030,base.$Background);
}
}
#uploadWindow {
img, video, audio {
width:100%;
}
}

20
src/style/error.scss Normal file
View file

@ -0,0 +1,20 @@
@use "_base";
.error {
font-size:20px;
color: lightslategray;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
text-align:center;
.code {
font-size:25px;
font-family: "Inconsolata", monospace;
color: white;
}
}

View file

@ -0,0 +1,168 @@
#uploadWindow {
color: #FFFFFF
}
body {
background-color:#DDDDDD;
}
#appContent {
background: darkgray;
@media screen and (max-width:500px) {
background:white;
}
}
#uploadWindow {
background: white;
color:black;
h1, p, a {
margin: 0px;
font-size: 14px;
}
a {
color:rgb(153, 153, 153);
}
h1 {
font-weight:600;
font-size: 25px;
text-align:center;
@media screen and (max-width:500px) {
font-size: 30px;
}
}
& > p:nth-of-type(1) {
text-align:center;
font-style: italic;
font-weight:600;
font-size: 16px;
color:black !important;
@media screen and (max-width:500px) {
font-size: 21px;
}
}
button {
cursor:pointer;
color:black;
border:none;
outline:none;
padding:5px;
background: #AAAAAA;
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
&:hover {
outline: 1px solid #333333;
color: black;
background-color:#AAAAAA;
}
}
& > button:nth-last-of-type(1) {
background-color:#66AAFF;
&:hover {
background-color:#66AAFF;
}
}
#add_new_files {
background-color: #AAAAAA66;
border:1px solid #AAAAAA;
#file_add_btns {
button, input[type=text] {
transition-duration:0s;
@media screen and (max-width: 500px) {
font-size:16px;
padding:10px;
}
}
input[type=text] {
font-family: "Fira Code", monospace;
background-color:#AAAAAA;
color:black;
}
button {
cursor:pointer;
background-color:#AAAAAA;
color: black;
&:hover {
flex-basis: 50%;
transition-duration:0s;
background-color:#AAAAAA;
color: black;
outline: 1px solid #333333;
}
}
.fileUpload {
background-color:#AAAAAA;
transition-duration:250ms;
&:hover {
transition-duration:0s;
background-color:#AAAAAA;
}
}
}
}
.file {
background-color: #AAAAAA66;
border: 1px solid #AAAAAA;
input[type=text] {
font-family: "Fira Code", monospace;
background-color:#AAAAAA;
color:black;
}
}
}
* {
/* nice scrollbars aren't needed on mobile so */
@media screen and (min-width:500px) {
&::-webkit-scrollbar {
width:5px;
}
&::-webkit-scrollbar-track {
background-color:#AAAAAA;
}
&::-webkit-scrollbar-thumb {
background-color:#DDDDDD;
&:hover {
background-color:#FFFFFF;
}
}
}
}
#topbar {
background-color: #DDDDDD;
}
.error {
.code {
color: black;
}
}

28
src/svelte/App.svelte Normal file
View file

@ -0,0 +1,28 @@
<script>
import { onMount } from "svelte";
import Topbar from "./elem/Topbar.svelte";
import PulldownManager from "./elem/PulldownManager.svelte";
import UploadWindow from "./elem/UploadWindow.svelte";
import { pulldownManager } from "./elem/stores.mjs";
/**
* @type Topbar
*/
let topbar;
/**
* @type PulldownManager
*/
let pulldown;
onMount(() => {
pulldownManager.set(pulldown)
})
</script>
<Topbar bind:this={topbar} pulldown={pulldown} />
<div id="appContent">
<PulldownManager bind:this={pulldown} />
<UploadWindow/>
</div>

View file

@ -0,0 +1,49 @@
<script context="module">
import { writable } from "svelte/store";
// can't find a better way to do this
import Files from "./pulldowns/Files.svelte";
import Accounts from "./pulldowns/Accounts.svelte";
import Help from "./pulldowns/Help.svelte";
export let allPulldowns = new Map()
allPulldowns
.set("account",Accounts)
.set("help",Help)
.set("files",Files)
export const pulldownOpen = writable(false);
</script>
<script>
import { onMount } from "svelte";
import { fade, scale } from "svelte/transition";
export function isOpen() {
return $pulldownOpen
}
export function openPulldown(name) {
pulldownOpen.set(name)
}
export function closePulldown() {
pulldownOpen.set(false)
}
onMount(() => {
})
</script>
{#if $pulldownOpen}
<div class="pulldown" transition:fade={{duration:200}}>
<svelte:component this={allPulldowns.get($pulldownOpen)} />
</div>
<button
id="overlay"
on:click={closePulldown}
transition:fade={{duration:200}}
/>
{/if}

View file

@ -0,0 +1,31 @@
<script>
import { circOut } from "svelte/easing";
import { scale } from "svelte/transition";
import PulldownManager, {pulldownOpen} from "./PulldownManager.svelte";
import { account } from "./stores.mjs";
import { _void } from "./transition/_void";
/**
* @type PulldownManager
*/
export let pulldown;
</script>
<div id="topbar">
{#if $pulldownOpen}
<button
class="menuBtn"
on:click={pulldown.closePulldown}
transition:_void={{duration:200,prop:"width",easingFunc:circOut}}
>close</button>
{/if}
<!-- too lazy to make this better -->
<button class="menuBtn" on:click={() => pulldown.openPulldown("files")}>files</button>
<button class="menuBtn" on:click={() => pulldown.openPulldown("account")}>{$account.username ? `@${$account.username}` : "account"}</button>
<button class="menuBtn" on:click={() => pulldown.openPulldown("help")}>help</button>
<div /> <!-- not sure what's offcenter but something is
so this div is here to ""fix"" that -->
</div>

View file

@ -0,0 +1,231 @@
<script>
import { _void } from "./transition/_void.js";
import { padding_scaleY } from "./transition/padding_scaleY.js"
import { fade } from "svelte/transition";
import { circIn, circOut } from "svelte/easing";
import { serverStats, refresh_stats, account } from "./stores.mjs";
import AttachmentZone from "./uploader/AttachmentZone.svelte";
// stats
refresh_stats()
// uploads
let attachmentZone;
let uploads = {};
let uploadInProgress = false;
let handle_file_upload = (ev) => {
if (ev.detail.type == "clone") {
uploads[Math.random().toString().slice(2)] = {
type: "clone",
name: ev.detail.url,
url: ev.detail.url,
params: {
uploadId: ""
},
uploadStatus:{
fileId: null,
error: null,
}
}
uploads = uploads
} else if (ev.detail.type == "upload") {
ev.detail.files.forEach((v,x) => {
uploads[Math.random().toString().slice(2)] = {
type: "upload",
name: v.name,
file: v,
params: {
uploadId: ""
},
uploadStatus:{
fileId: null,
error: null,
}
}
})
uploads = uploads
}
}
let handle_fetch_promise = (x,prom) => {
return prom.then(async (res) => {
let txt = await res.text()
if (txt.startsWith("[err]")) uploads[x].uploadStatus.error = txt;
else {
uploads[x].uploadStatus.fileId = txt;
refresh_stats();
}
}).catch((err) => {
uploads[x].uploadStatus.error = err.toString();
})
}
let upload_files = async () => {
uploadInProgress = true
let sequential = localStorage.getItem("sequentialMode") == "true"
// go through all files
for (let [x,v] of Object.entries(uploads)) {
// quick patch-in to allow for a switch to have everything upload sequentially
// switch will have a proper menu option later, for now i'm lazy so it's just gonna be a Secret
let hdl = () => {
switch(v.type) {
case "upload":
let fd = new FormData()
fd.append("file",v.file)
return handle_fetch_promise(x,fetch("/upload",{
headers: {
"monofile-params": JSON.stringify(v.params)
},
method: "POST",
body: fd
}))
break
case "clone":
return handle_fetch_promise(x,fetch("/clone",{
method: "POST",
body: JSON.stringify({
url: v.url,
...v.params
})
}))
break
}
}
if (sequential) await hdl();
else hdl();
}
}
// animation
function fileTransition(node) {
return {
duration: 300,
css: t => {
let eased = circOut(t)
return `
height: ${eased*(node.offsetHeight-22)}px;
padding: ${eased*10}px 10px;
`
}
}
}
</script>
<div id="uploadWindow">
<h1>monofile</h1>
<p style:color="#999999">
<span class="number">{$serverStats.version ? `v${$serverStats.version}` : "•••"}</span>&nbsp;&nbsp;&nbsp;&nbsp;Discord based file sharing
</p>
<div style:min-height="10px" />
<!-- consider splitting the file thing into a separate element maybe -->
<div class="uploadContainer">
{#each Object.entries(uploads) as upload (upload[0])}
<!-- container to allow for animate directive -->
<div>
<div class="file" transition:fileTransition style:border={upload[1].uploadStatus.error ? "1px solid #BB7070" : ""}>
<h2>{upload[1].name} <span style:color="#999999" style:font-weight="400">{upload[1].type}{@html upload[1].type == "upload" ? `&nbsp;(${Math.round(upload[1].file.size/1048576)}MiB)` : ""}</span></h2>
{#if upload[1].maximized && !uploadInProgress}
<div transition:padding_scaleY|local>
<div style:height="10px" />
<input placeholder="custom id" type="text" bind:value={ uploads[upload[0]].params.uploadId }>
<div style:height="10px" />
<div class="buttonContainer">
<button on:click={() => {delete uploads[upload[0]];uploads=uploads;}}>
delete
</button>
<button on:click={() => uploads[upload[0]].maximized = false}>
minimize
</button>
</div>
</div>
{:else if !uploadInProgress}
<button on:click={() => uploads[upload[0]].maximized = true} class="hitbox"></button>
{:else}
<div transition:padding_scaleY|local class="uploadingContainer">
{#if !upload[1].uploadStatus.fileId}
<p in:fade={{duration:300, delay:400, easingFunc:circOut}} out:padding_scaleY={{easingFunc:circIn,op:true}}>{upload[1].uploadStatus.error ?? "Uploading..."}</p>
{/if}
{#if upload[1].uploadStatus.fileId}
<div style:height="10px" transition:padding_scaleY />
{#if !upload[1].viewingUrl}
<div class="buttonContainer" out:_void in:_void={{easingFunc:circOut}}>
<button on:click={() => uploads[upload[0]].viewingUrl = true}>
view url
</button>
<button on:click={() => navigator.clipboard.writeText(`https://${window.location.host}/download/${upload[1].uploadStatus.fileId}`)}>
copy url
</button>
</div>
{:else}
<div class="buttonContainer" out:_void in:_void={{easingFunc:circOut}}>
<input type="text" readonly value={`https://${window.location.host}/download/${upload[1].uploadStatus.fileId}`} style:flex-basis="80%">
<button on:click={() => uploads[upload[0]].viewingUrl = false} style:flex-basis="20%">
ok
</button>
</div>
{/if}
{/if}
</div>
{/if}
</div>
<div style:height="10px" transition:padding_scaleY />
</div>
{/each}
</div>
{#if uploadInProgress == false}
<!-- if required for upload, check if logged in -->
{#if ($serverStats.accounts||{}).requiredForUpload ? !!$account.username : true}
<AttachmentZone bind:this={attachmentZone} on:addFiles={handle_file_upload}/>
<div style:min-height="10px" transition:_void={{rTarg:"height",prop:"min-height"}} />
{#if Object.keys(uploads).length > 0}
<button in:padding_scaleY={{easingFunc:circOut}} out:_void on:click={upload_files}>upload</button>
<div transition:_void={{rTarg:"height",prop:"min-height"}} style:min-height="10px" />
{/if}
{:else}
<p transition:_void style:color="#999999" style:text-align="center">Please log in to upload files.</p>
<div transition:_void={{rTarg:"height",prop:"min-height"}} style:min-height="10px" />
{/if}
{/if}
<p style:color="#999999" style:text-align="center">
Hosting <span class="number" style:font-weight="600">{$serverStats.files || "•••"}</span> files
Maximum filesize is <span class="number" style:font-weight="600">{(($serverStats.maxDiscordFileSize || 0)*($serverStats.maxDiscordFiles || 0))/1048576 || "•••"}MiB</span>
<br />
</p>
<p style:color="#999999" style:text-align="center" style:font-size="12px">
Made with {Math.floor(Math.random()*10)==0 ? "🐟" : "❤"} by <a href="https://github.com/nbitzz" style:font-size="12px">@nbitzz</a><a href="https://github.com/nbitzz/monofile" style:font-size="12px">source</a>
</p>
<div style:height="10px" />
</div>

View file

@ -0,0 +1,78 @@
<script>
import { fade, slide } from "svelte/transition";
let activeModal;
let modalResults;
/**
*
* @param mdl {name:string,icon:string,description:string,id:string}[]
* @returns Promise
*/
export function picker(title,mdl) {
if (activeModal) forceCancel()
return new Promise((resolve,reject) => {
activeModal = {
resolve,
title,
modal:mdl
}
modalResults = {
}
})
}
export function forceCancel() {
if (activeModal && activeModal.resolve) {
activeModal.resolve(null)
}
activeModal = null
}
</script>
{#if activeModal}
<div class="modalContainer" transition:fade={{duration:200}}>
<button class="mdHitbox" on:click|self={forceCancel}></button>
<div class="modal" transition:slide={{duration:200}}>
<div class="optPicker">
<div class="category">
<p style:margin-bottom="10px">{activeModal.title}</p>
</div>
{#each activeModal.modal as option (option.id)}
{#if option.inputSettings}
<div class="inp">
<img src={option.icon} alt={option.id}>
<!-- i have to do this stupidness because of svelte but -->
<!-- its reason for blocking this is pretty good sooooo -->
{#if option.inputSettings.password}
<input placeholder={option.name} type="password" bind:value={modalResults[option.id]}>
{:else}
<input placeholder={option.name} bind:value={modalResults[option.id]}>
{/if}
</div>
{:else}
<button on:click={() => {activeModal.resolve({...modalResults,selected:option.id});activeModal=null;modalResults=null;}}>
<img src={option.icon} alt={option.id}>
<p>{option.name}<span><br />{option.description}</span></p>
</button>
{/if}
{/each}
<button on:click={forceCancel}>
<img src="/static/assets/icons/delete.svg" alt="cancel">
<p>Cancel</p>
</button>
</div>
</div>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show more