Initial release
This commit is contained in:
@@ -1,8 +1,19 @@
|
||||
# EditorConfig is awesome: https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
indent_size = tab
|
||||
indent_style = tab
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
indent_style = spaces
|
||||
|
||||
[*.md]
|
||||
indent_style = tab
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
60
.gitignore
vendored
60
.gitignore
vendored
@@ -1,17 +1,49 @@
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Whether you use PnP or not, the node_modules folder is often used to store
|
||||
# build artifacts that should be gitignored
|
||||
node_modules
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Swap the comments on the following lines if you wish to use zero-installs
|
||||
# In that case, don't forget to run `yarn config set enableGlobalCache false`!
|
||||
# Documentation here: https://yarnpkg.com/features/caching#zero-installs
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
#!.yarn/cache
|
||||
.pnp.*
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn setups files
|
||||
.yarn
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# twtkpr data file directory
|
||||
.data
|
||||
|
||||
TODO.md
|
||||
|
||||
11
.prettierrc.json
Normal file
11
.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
#enableImmutableInstalls: true
|
||||
npmMinimalAgeGate: 7d
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Eric Woodward [https://www.itsericwoodward.com]
|
||||
|
||||
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.
|
||||
187
README.md
187
README.md
@@ -1 +1,186 @@
|
||||
# express-twtkpr-upload-button
|
||||
# express-twtkpr-core-plugins
|
||||
|
||||
> [!WARNING]
|
||||
> **STILL IN ALPHA**: Although this is a fully-functional set of plugins which are actively deployed to at least one
|
||||
> site (my own), it currently lacks documentation, examples, tests, installation flexibility, or polish (and still has
|
||||
> a couple of bugs that need to be fixed).
|
||||
> _USE AT YOUR OWN RISK_
|
||||
|
||||
A collection of 3 recommended plugins to expand the functionality of your `express-twtkpr` installation.
|
||||
|
||||
These plugins include:
|
||||
|
||||
- [`emojiButton`](#emojibutton): Adds a simple button to the textarea which appends an emoji to your twt.
|
||||
- [`postToMastodon`](#posttomastodon): Enables automatic posting to your Mastodon account.
|
||||
- [`uploadButton`](#uploadbutton): Adds support for drag-and-drop and button-driven file uploads, with optional
|
||||
automatic hashing, renaming, metadata stripping, and resizing of images.
|
||||
|
||||
```
|
||||
import express, { Request, Response } from "express";
|
||||
|
||||
import twtkpr from "express-twtkpr";
|
||||
import {
|
||||
emojiButton,
|
||||
postToMastodon,
|
||||
uploadButton,
|
||||
} from "express-twtkpr-core-plugins";
|
||||
|
||||
// import other middleware
|
||||
|
||||
const app = express();
|
||||
|
||||
// add other middleware
|
||||
|
||||
app.use(
|
||||
"/",
|
||||
twtkpr(
|
||||
{
|
||||
// config options
|
||||
},
|
||||
[emojiButton, postToMastodon, uploadButton],
|
||||
),
|
||||
);
|
||||
|
||||
// add error handler
|
||||
|
||||
export default app;
|
||||
|
||||
```
|
||||
|
||||
## emojiButton
|
||||
|
||||
Adds a simple button to the textarea which appends an emoji to your twt.
|
||||
|
||||
- Uses [jwz's](https://www.jwz.org/) [emoji.js](https://www.dnalounge.com/webcast/emoji.js) and [emoji.css](https://www.dnalounge.com/webcast/emoji.css) files to generate the button.
|
||||
- Keeps track of recently used emoji.
|
||||
- Includes support for a _massive list_ of emojis.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
import express, { Request, Response } from "express";
|
||||
|
||||
import twtkpr from "express-twtkpr";
|
||||
import {
|
||||
emojiButton,
|
||||
postToMastodon,
|
||||
uploadButton,
|
||||
} from "express-twtkpr-core-plugins";
|
||||
|
||||
// import other middleware
|
||||
|
||||
const app = express();
|
||||
|
||||
// add other middleware
|
||||
|
||||
app.use(
|
||||
"/",
|
||||
twtkpr(
|
||||
{
|
||||
// config options
|
||||
},
|
||||
[emojiButton],
|
||||
),
|
||||
);
|
||||
|
||||
// add error handler
|
||||
|
||||
export default app;
|
||||
|
||||
```
|
||||
|
||||
## postToMastodon
|
||||
|
||||
Enables automatic posting to your Mastodon account.
|
||||
|
||||
- Follows principle of POSSE (Publish Own Site, Syndicate Elsewhere)
|
||||
|
||||
### Usage
|
||||
|
||||
For it to work, you _MUST_ include both of the following values:
|
||||
|
||||
- `application_token`: On your Mastodon client, go to "Preferences", then "Development". Create a
|
||||
new application, then look for the value "Your access token".
|
||||
- `server_url`: The root URL for your Mastodon server (ex: "https://toot.cafe")
|
||||
|
||||
The preferred way to include them _WILL BE_ via ENV variables (once the work is complete).
|
||||
|
||||
```
|
||||
import express, { Request, Response } from "express";
|
||||
|
||||
import twtkpr from "express-twtkpr";
|
||||
import {
|
||||
emojiButton,
|
||||
postToMastodon,
|
||||
uploadButton,
|
||||
} from "express-twtkpr-core-plugins";
|
||||
|
||||
// import other middleware
|
||||
|
||||
const app = express();
|
||||
|
||||
// add other middleware
|
||||
|
||||
app.use(
|
||||
"/",
|
||||
twtkpr(
|
||||
{
|
||||
plugins: {
|
||||
postToMastodon: {
|
||||
application_token: "<TOKEN>", // or via ENV variable TWTKPR_PLUGIN_PTM_APP_TOKEN
|
||||
server_url: "<URL>", // or via ENV variable TWTKPR_PLUGIN_PTM_APP_TOKEN
|
||||
},
|
||||
},
|
||||
// other config options
|
||||
},
|
||||
[postToMastodon],
|
||||
),
|
||||
);
|
||||
|
||||
// add error handler
|
||||
|
||||
export default app;
|
||||
|
||||
```
|
||||
|
||||
## uploadButton
|
||||
|
||||
Adds support for drag-and-drop and button-driven file uploads, with optional automatic hashing,
|
||||
renaming, metadata stripping, and resizing of images.
|
||||
|
||||
```
|
||||
import express, { Request, Response } from "express";
|
||||
|
||||
import twtkpr from "express-twtkpr";
|
||||
import {
|
||||
emojiButton,
|
||||
postToMastodon,
|
||||
uploadButton,
|
||||
} from "express-twtkpr-core-plugins";
|
||||
|
||||
// import other middleware
|
||||
|
||||
const app = express();
|
||||
|
||||
// add other middleware
|
||||
|
||||
app.use(
|
||||
"/",
|
||||
twtkpr(
|
||||
{
|
||||
// config options
|
||||
},
|
||||
[uploadButton],
|
||||
),
|
||||
);
|
||||
|
||||
// add error handler
|
||||
|
||||
export default app;
|
||||
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Copyright (c) 2026 Eric Woodward, released under the
|
||||
[MIT License](https://www.itsericwoodward.com/licenses/mit/).
|
||||
|
||||
43
dist/package.json
vendored
Normal file
43
dist/package.json
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "express-twtkpr-core-plugins",
|
||||
"version": "0.9.0",
|
||||
"packageManager": "yarn@4.14.1",
|
||||
"type": "module",
|
||||
"main": "./dist/src/index.js",
|
||||
"module": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/src/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && rsync -avm --include '*/' --include '*/client/*.*' --exclude '*' src/ dist/src",
|
||||
"lint": "eslint --fix src test",
|
||||
"prepublishOnly": "yarn build",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"express": "^5.2.1",
|
||||
"express-twtkpr": "^0.9.0",
|
||||
"formidable": "^3.5.4",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/formidable": "^3.5.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
1
dist/src/constants.d.ts
vendored
Normal file
1
dist/src/constants.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare const __dirname: string;
|
||||
4
dist/src/constants.js
vendored
Normal file
4
dist/src/constants.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
export const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
//# sourceMappingURL=constants.js.map
|
||||
1
dist/src/constants.js.map
vendored
Normal file
1
dist/src/constants.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,CAAC,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC"}
|
||||
114
dist/src/emojiButton/client/emoji.css
vendored
Normal file
114
dist/src/emojiButton/client/emoji.css
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
/* Copyright © 2020 Jamie Zawinski <jwz@dnalounge.com>
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell this software
|
||||
and its documentation for any purpose is hereby granted without
|
||||
fee, provided that the above copyright notice appear in all copies
|
||||
and that both that copyright notice and this permission notice
|
||||
appear in supporting documentation. No representations are made
|
||||
about the suitability of this software for any purpose. It is
|
||||
provided "as is" without express or implied warranty.
|
||||
|
||||
Emoji popup menu. There are many like it. This one is mine.
|
||||
|
||||
Created: 24-May-2020
|
||||
*/
|
||||
|
||||
#emoji_blocker {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.emoji_menu {
|
||||
position: fixed;
|
||||
display: inline-block;
|
||||
border: 1px solid;
|
||||
width: 22em;
|
||||
text-align: center;
|
||||
z-index: 1001;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.emoji_tab_bar {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji_tab {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1.8em;
|
||||
height: 1.3em;
|
||||
padding: 0px 2px 8px 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji_tab.selected {
|
||||
background: #040;
|
||||
}
|
||||
|
||||
.emoji_page_box {
|
||||
height: 16em;
|
||||
overflow-y: auto;
|
||||
background: #040;
|
||||
}
|
||||
|
||||
.emoji_page {
|
||||
display: none;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emoji_page > b {
|
||||
display: block;
|
||||
font-size: smaller;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.emoji_square {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
margin: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji_button {
|
||||
display: inline-block;
|
||||
font-size: smaller;
|
||||
padding: 0 4px 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Light modifications by Eric Woodward<hey@itsericwoodward,com> */
|
||||
|
||||
.emoji_button {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.emoji_menu {
|
||||
background: rgb(10, 10, 20, 0.6);
|
||||
}
|
||||
|
||||
.emoji_tab.selected {
|
||||
background: rgb(27, 27, 39);
|
||||
}
|
||||
|
||||
.emoji_page_box {
|
||||
background: rgb(27, 27, 39);
|
||||
}
|
||||
|
||||
.twtControls-contentInput {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.twtControls-contentLabel {
|
||||
position: relative;
|
||||
}
|
||||
2160
dist/src/emojiButton/client/emoji.js
vendored
Normal file
2160
dist/src/emojiButton/client/emoji.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
dist/src/emojiButton/index.d.ts
vendored
Normal file
1
dist/src/emojiButton/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './plugin.js';
|
||||
2
dist/src/emojiButton/index.js
vendored
Normal file
2
dist/src/emojiButton/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './plugin.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
dist/src/emojiButton/index.js.map
vendored
Normal file
1
dist/src/emojiButton/index.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/emojiButton/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
|
||||
3
dist/src/emojiButton/plugin.d.ts
vendored
Normal file
3
dist/src/emojiButton/plugin.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { TwtKprPluginConfiguration } from 'express-twtkpr';
|
||||
declare const _default: () => TwtKprPluginConfiguration;
|
||||
export default _default;
|
||||
32
dist/src/emojiButton/plugin.js
vendored
Normal file
32
dist/src/emojiButton/plugin.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getReadStream } from 'express-twtkpr';
|
||||
import { __dirname } from '../constants.js';
|
||||
console.log({ __dirname });
|
||||
const PLUGIN_NAME = 'emojiButton';
|
||||
export default () => ({
|
||||
clientCSS: () => {
|
||||
const cssStream = fs.createReadStream(join(__dirname, 'src', PLUGIN_NAME, 'client', 'emoji.css'));
|
||||
cssStream.on('error', (err) => {
|
||||
console.error(err);
|
||||
cssStream.close();
|
||||
cssStream.push(null);
|
||||
});
|
||||
return cssStream;
|
||||
},
|
||||
clientJS: () => {
|
||||
const jsFileStream = getReadStream(join(__dirname, 'src', PLUGIN_NAME, 'client', 'emoji.js'));
|
||||
/*
|
||||
const jsStream = Readable.from(`
|
||||
const twtSubmitButton = document.querySelector('.twtControls-submitButton');
|
||||
const emojiTarget = document.querySelector('.emoji_target');
|
||||
emojiTarget.addEventListener('change', () => {
|
||||
if (twtSubmitButton) twtSubmitButton.removeAttribute('disabled');
|
||||
});
|
||||
`);
|
||||
*/
|
||||
return jsFileStream;
|
||||
},
|
||||
name: PLUGIN_NAME,
|
||||
});
|
||||
//# sourceMappingURL=plugin.js.map
|
||||
1
dist/src/emojiButton/plugin.js.map
vendored
Normal file
1
dist/src/emojiButton/plugin.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../../../src/emojiButton/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAIjC,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE/C,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,OAAO,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC;AAE3B,MAAM,WAAW,GAAG,aAAa,CAAC;AAElC,eAAe,GAA8B,EAAE,CAAC,CAAC;IAChD,SAAS,EAAE,GAAG,EAAE;QACf,MAAM,SAAS,GAAG,EAAE,CAAC,gBAAgB,CACpC,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,WAAW,CAAC,CAC1D,CAAC;QAEF,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YAC7B,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACnB,SAAS,CAAC,KAAK,EAAE,CAAC;YAClB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;QAEH,OAAO,SAAS,CAAC;IAClB,CAAC;IAED,QAAQ,EAAE,GAAG,EAAE;QACd,MAAM,YAAY,GAAG,aAAa,CACjC,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,CAAC,CACzD,CAAC;QACF;;;;;;;;UAQE;QAEF,OAAO,YAAY,CAAC;IACrB,CAAC;IAED,IAAI,EAAE,WAAW;CACjB,CAAC,CAAC"}
|
||||
3
dist/src/index.d.ts
vendored
Normal file
3
dist/src/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as emojiButton } from './emojiButton/index.js';
|
||||
export { default as postToMastodon } from './postToMastodon/index.js';
|
||||
export { default as uploadButton } from './uploadButton/index.js';
|
||||
4
dist/src/index.js
vendored
Normal file
4
dist/src/index.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as emojiButton } from './emojiButton/index.js';
|
||||
export { default as postToMastodon } from './postToMastodon/index.js';
|
||||
export { default as uploadButton } from './uploadButton/index.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
dist/src/index.js.map
vendored
Normal file
1
dist/src/index.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,yBAAyB,CAAC"}
|
||||
34
dist/src/postToMastodon/client/script.js
vendored
Normal file
34
dist/src/postToMastodon/client/script.js
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
(() => {
|
||||
const twtForm = document.getElementById('twtForm');
|
||||
|
||||
/*
|
||||
document
|
||||
.querySelector('.twtControls-contentLabel')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton());
|
||||
|
||||
document
|
||||
.querySelector('#twtControlsEditButton')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton('small'));
|
||||
|
||||
const twtControlsContentInput = document.getElementById(
|
||||
'twtControlsContentInput'
|
||||
);
|
||||
|
||||
twtControlsContentInput.addEventListener('drop', dropHandler);
|
||||
|
||||
twtControlsContentInput.addEventListener('dragover', dragOverHandler);
|
||||
|
||||
window.addEventListener('dragover', dragOverWindowHandler);
|
||||
|
||||
window.addEventListener('drop', dropWindowHandler);
|
||||
|
||||
Array.from(document.querySelectorAll('.twtControls-uploadInput')).forEach(
|
||||
(uploadInput) => {
|
||||
uploadInput.addEventListener('change', uploadChangeHandler);
|
||||
}
|
||||
);
|
||||
*/
|
||||
})();
|
||||
// @license-end
|
||||
1
dist/src/postToMastodon/index.d.ts
vendored
Normal file
1
dist/src/postToMastodon/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './plugin.js';
|
||||
2
dist/src/postToMastodon/index.js
vendored
Normal file
2
dist/src/postToMastodon/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './plugin.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
dist/src/postToMastodon/index.js.map
vendored
Normal file
1
dist/src/postToMastodon/index.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/postToMastodon/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
|
||||
3
dist/src/postToMastodon/plugin.d.ts
vendored
Normal file
3
dist/src/postToMastodon/plugin.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { TwtKprConfiguration, TwtKprPluginConfiguration } from 'express-twtkpr';
|
||||
declare const _default: (config: TwtKprConfiguration) => TwtKprPluginConfiguration;
|
||||
export default _default;
|
||||
59
dist/src/postToMastodon/plugin.js
vendored
Normal file
59
dist/src/postToMastodon/plugin.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
import Debug from 'debug';
|
||||
const PLUGIN_NAME = 'postToMastodon';
|
||||
// curl https://toot.cafe/api/v1/statuses -H 'Authorization: Bearer A0k6vbobTQvG-n9DStsfGV03BKxKkZHL-IhljR3Lvik' -F 'status=Test posting from cURL via the API. Hello from the command line!'
|
||||
export default (config) => {
|
||||
const debug = Debug(`twtkprPlugin:${PLUGIN_NAME}`);
|
||||
const { application_token, server_url } = config?.plugins?.[PLUGIN_NAME] ?? {};
|
||||
if (!application_token || !server_url)
|
||||
return {};
|
||||
return {
|
||||
onAfterTwt: async (twt) => {
|
||||
// TODO: add some console.log / error to output things that we can't return a message for
|
||||
// That way, it shows up _somewhere_
|
||||
// Don't do anything if the twt is a reply (starts with a hash)
|
||||
const [, content] = twt.split(/\t/);
|
||||
if (content.match(/^\(#(\w+)\)/)) {
|
||||
// it's a reply, skip
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('status', content.replace(/\s*\u2028/g, '\n'));
|
||||
debug(`Sending message to Mastodon instance at ${server_url}`);
|
||||
const res = await fetch(`${server_url}${server_url.endsWith('/') ? '' : '/'}api/v1/statuses`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${application_token}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Bad response (${res.status}) from Mastodon server ${server_url}: ${res.statusText}`);
|
||||
return;
|
||||
}
|
||||
const result = await res.text();
|
||||
console.log(`Twt sent to Mastodon server ${server_url}, response:`, {
|
||||
result,
|
||||
});
|
||||
},
|
||||
// use JS to add a checkbox to the form
|
||||
// update the Twt post function to send the whole form - that will
|
||||
// allow you to add new things to it and they will be sent
|
||||
/*
|
||||
clientJS: () => {
|
||||
const jsStream = fs.createReadStream(
|
||||
path.join(__dirname, 'plugins', PLUGIN_NAME, 'script.js')
|
||||
);
|
||||
|
||||
jsStream.on('error', (err) => {
|
||||
console.error(err);
|
||||
jsStream.close();
|
||||
jsStream.push(null);
|
||||
});
|
||||
|
||||
return jsStream;
|
||||
},
|
||||
*/
|
||||
name: PLUGIN_NAME,
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=plugin.js.map
|
||||
1
dist/src/postToMastodon/plugin.js.map
vendored
Normal file
1
dist/src/postToMastodon/plugin.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../../../src/postToMastodon/plugin.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,MAAM,WAAW,GAAG,gBAAgB,CAAC;AAErC,6LAA6L;AAE7L,eAAe,CAAC,MAA2B,EAA6B,EAAE;IACzE,MAAM,KAAK,GAAG,KAAK,CAAC,gBAAgB,WAAW,EAAE,CAAC,CAAC;IAEnD,MAAM,EAAE,iBAAiB,EAAE,UAAU,EAAE,GACtC,MAAM,EAAE,OAAO,EAAE,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC;IAEtC,IAAI,CAAC,iBAAiB,IAAI,CAAC,UAAU;QAAE,OAAO,EAAE,CAAC;IAEjD,OAAO;QACN,UAAU,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;YACzB,yFAAyF;YACzF,oCAAoC;YAEpC,+DAA+D;YAC/D,MAAM,CAAC,EAAE,OAAO,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACpC,IAAI,OAAO,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC;gBAClC,qBAAqB;gBACrB,OAAO;YACR,CAAC;YAED,MAAM,QAAQ,GAAG,IAAI,QAAQ,EAAE,CAAC;YAChC,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,OAAO,CAAC,YAAY,EAAE,IAAI,CAAC,CAAC,CAAC;YAE/D,KAAK,CAAC,2CAA2C,UAAU,EAAE,CAAC,CAAC;YAE/D,MAAM,GAAG,GAAG,MAAM,KAAK,CACtB,GAAG,UAAU,GAAG,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,iBAAiB,EACpE;gBACC,MAAM,EAAE,MAAM;gBACd,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE;oBACR,aAAa,EAAE,UAAU,iBAAiB,EAAE;iBAC5C;aACD,CACD,CAAC;YAEF,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;gBACb,OAAO,CAAC,KAAK,CACZ,iBAAiB,GAAG,CAAC,MAAM,0BAC1B,UACD,KAAK,GAAG,CAAC,UAAU,EAAE,CACrB,CAAC;gBACF,OAAO;YACR,CAAC;YAED,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;YAEhC,OAAO,CAAC,GAAG,CAAC,+BAA+B,UAAU,aAAa,EAAE;gBACnE,MAAM;aACN,CAAC,CAAC;QACJ,CAAC;QAED,uCAAuC;QACvC,kEAAkE;QAClE,0DAA0D;QAE1D;;;;;;;;;;;;;;UAcQ;QAER,IAAI,EAAE,WAAW;KACjB,CAAC;AACH,CAAC,CAAC"}
|
||||
154
dist/src/uploadButton/client/script.js
vendored
Normal file
154
dist/src/uploadButton/client/script.js
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
const injectUploadButton = (route) => {
|
||||
// const route = '/files';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param uploadConfiguration
|
||||
* @param variant
|
||||
* @returns
|
||||
*/
|
||||
const renderUploadButton = (variant = 'normal') => `
|
||||
<label class="button twtControls-uploadInputLabel twtControls-uploadInputLabel-${variant}"
|
||||
for="twtControlsUploadInput-${variant}"
|
||||
>
|
||||
Upload${variant === 'normal' ? '<br />' : ' '}Files
|
||||
<input accept="*" class="twtControls-uploadInput"
|
||||
id="twtControlsUploadInput-${variant}"
|
||||
multiple type="file" />
|
||||
</label>
|
||||
`;
|
||||
|
||||
const uploadFiles = async (files, uploadRoute, secondAttempt = false) => {
|
||||
if (!uploadRoute || !window.token || !window.refreshToken) return;
|
||||
|
||||
debug('uploadFiles', token, files, uploadRoute, secondAttempt);
|
||||
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', files[i]);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(uploadRoute, {
|
||||
method: 'POST',
|
||||
type: 'upload',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`File${files.length !== 1 ? 's' : ''} uploaded`);
|
||||
|
||||
const filePath = await res.text();
|
||||
twtControlsContentInput.value += filePath
|
||||
.split('\n')
|
||||
.map((currFilePath) =>
|
||||
[
|
||||
' ',
|
||||
location.protocol,
|
||||
'//',
|
||||
location.hostname,
|
||||
location.protocol !== 'https:' && location.port !== 80
|
||||
? ':' + location.port
|
||||
: '',
|
||||
currFilePath,
|
||||
].join('')
|
||||
)
|
||||
.join('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!secondAttempt) {
|
||||
await refreshToken();
|
||||
return uploadFiles(files, uploadRoute, true);
|
||||
}
|
||||
|
||||
showToast(
|
||||
`Unable to upload image${files.length !== 1 ? 's' : ''} refresh token, please try again later.`,
|
||||
'error'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
/* Handlers */
|
||||
|
||||
const dragOverHandler = (ev) => {
|
||||
const files = [...ev.dataTransfer.items].filter(
|
||||
(item) => item.kind === 'file'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const dragOverWindowHandler = (ev) => {
|
||||
const files = [...ev.dataTransfer.items].filter(
|
||||
(item) => item.kind === 'file'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!twtControlsContentInput.contains(ev.target)) {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dropHandler = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const files = [...ev.dataTransfer.items]
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file);
|
||||
|
||||
debug('dropHandler', files);
|
||||
uploadFiles(files, route);
|
||||
};
|
||||
|
||||
const dropWindowHandler = (ev) => {
|
||||
if ([...ev.dataTransfer.items].some((item) => item.kind === 'file')) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const uploadChangeHandler = (ev) => {
|
||||
uploadFiles(ev.target.files, route);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelector('.twtControls-contentLabel')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton());
|
||||
|
||||
document
|
||||
.querySelector('#twtControlsEditButton')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton('small'));
|
||||
|
||||
const twtControlsContentInput = document.getElementById(
|
||||
'twtControlsContentInput'
|
||||
);
|
||||
|
||||
twtControlsContentInput.addEventListener('drop', dropHandler);
|
||||
|
||||
twtControlsContentInput.addEventListener('dragover', dragOverHandler);
|
||||
|
||||
window.addEventListener('dragover', dragOverWindowHandler);
|
||||
|
||||
window.addEventListener('drop', dropWindowHandler);
|
||||
|
||||
Array.from(document.querySelectorAll('.twtControls-uploadInput')).forEach(
|
||||
(uploadInput) => {
|
||||
uploadInput.addEventListener('change', uploadChangeHandler);
|
||||
}
|
||||
);
|
||||
};
|
||||
// @license-end
|
||||
37
dist/src/uploadButton/client/styles.css
vendored
Normal file
37
dist/src/uploadButton/client/styles.css
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Begin TwtKpr UploadPlugin CSS
|
||||
*/
|
||||
.twtControls-uploadInputLabel {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
.twtControls-uploadInputLabel-normal {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
|
||||
.twtControls-uploadInputLabel-small {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel:hover {
|
||||
background-color: var(--bg-hl);
|
||||
color: var(--fg-hl);
|
||||
}
|
||||
|
||||
/*
|
||||
.twtControls-uploadInput {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
12
dist/src/uploadButton/defaults.d.ts
vendored
Normal file
12
dist/src/uploadButton/defaults.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare const _default: {
|
||||
allowedMimeTypes: string;
|
||||
directory: string;
|
||||
imageFit: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
route: string;
|
||||
hashAlgorithm: string;
|
||||
keepExtensions: boolean;
|
||||
maxFiles: number;
|
||||
};
|
||||
export default _default;
|
||||
39
dist/src/uploadButton/defaults.js
vendored
Normal file
39
dist/src/uploadButton/defaults.js
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
uploadConfiguration: {
|
||||
...uploadConfiguration,
|
||||
active: uploadActive,
|
||||
allowEmptyFiles,
|
||||
allowedMimeTypes: getDestinationByMimeTypeConfiguration(allowedMimeTypes),
|
||||
createDirsFromUploads,
|
||||
directory,
|
||||
encoding,
|
||||
fileWriteStreamHandler,
|
||||
filter,
|
||||
hashAlgorithm: hashAlgorithm as string | false | undefined,
|
||||
keepExtensions,
|
||||
maxFields,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
maxTotalFileSize,
|
||||
minFileSize,
|
||||
route,
|
||||
},
|
||||
*/
|
||||
// falls back to formidable defaults where it can
|
||||
export default {
|
||||
// local values
|
||||
allowedMimeTypes: '',
|
||||
directory: 'public',
|
||||
imageFit: 'inside',
|
||||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
route: 'files',
|
||||
// uploadEncoding: 'utf-8',
|
||||
// defaults for formidable
|
||||
// allowEmptyFiles: false, // same as formidable
|
||||
// encoding: 'utf-8', // same as formidable
|
||||
hashAlgorithm: 'sha256',
|
||||
keepExtensions: true, // TODO: verify this is necessary
|
||||
maxFiles: 10,
|
||||
};
|
||||
//# sourceMappingURL=defaults.js.map
|
||||
1
dist/src/uploadButton/defaults.js.map
vendored
Normal file
1
dist/src/uploadButton/defaults.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"defaults.js","sourceRoot":"","sources":["../../../src/uploadButton/defaults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;EAoBE;AAEF,iDAAiD;AACjD,eAAe;IACd,eAAe;IACf,gBAAgB,EAAE,EAAE;IACpB,SAAS,EAAE,QAAQ;IACnB,QAAQ,EAAE,QAAQ;IAClB,WAAW,EAAE,IAAI;IACjB,UAAU,EAAE,IAAI;IAChB,KAAK,EAAE,OAAO;IACd,2BAA2B;IAE3B,0BAA0B;IAC1B,iDAAiD;IACjD,4CAA4C;IAC5C,aAAa,EAAE,QAAQ;IACvB,cAAc,EAAE,IAAI,EAAE,iCAAiC;IACvD,QAAQ,EAAE,EAAE;CACZ,CAAC"}
|
||||
1
dist/src/uploadButton/index.d.ts
vendored
Normal file
1
dist/src/uploadButton/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './plugin.js';
|
||||
2
dist/src/uploadButton/index.js
vendored
Normal file
2
dist/src/uploadButton/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './plugin.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
dist/src/uploadButton/index.js.map
vendored
Normal file
1
dist/src/uploadButton/index.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/uploadButton/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC"}
|
||||
3
dist/src/uploadButton/plugin.d.ts
vendored
Normal file
3
dist/src/uploadButton/plugin.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { TwtKprConfiguration, TwtKprPluginConfiguration } from 'express-twtkpr';
|
||||
declare const _default: (config: TwtKprConfiguration) => TwtKprPluginConfiguration;
|
||||
export default _default;
|
||||
167
dist/src/uploadButton/plugin.js
vendored
Normal file
167
dist/src/uploadButton/plugin.js
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import Debug from 'debug';
|
||||
import { combineStreams, getReadStream } from 'express-twtkpr';
|
||||
import formidable from 'formidable';
|
||||
import sharp from 'sharp';
|
||||
import { __dirname } from '../constants.js';
|
||||
import defaults from './defaults.js';
|
||||
import { getDestinationByMimeTypeConfiguration } from './utils.js';
|
||||
const PLUGIN_NAME = 'uploadButton';
|
||||
export default (config) => {
|
||||
const debug = Debug(`twtkprPlugin:${PLUGIN_NAME}}`);
|
||||
const { route = '/files' } = config?.plugins?.[PLUGIN_NAME] ?? {};
|
||||
return {
|
||||
clientCSS: () => {
|
||||
const stream = getReadStream(join(__dirname, 'src', PLUGIN_NAME, 'client', 'styles.css'));
|
||||
return stream;
|
||||
},
|
||||
clientJS: () => {
|
||||
const jsStream = getReadStream(join(__dirname, 'src', PLUGIN_NAME, 'client', 'script.js'));
|
||||
const jsCallerStream = Readable.from(`injectUploadButton('${route}');`);
|
||||
return combineStreams([jsStream, jsCallerStream]);
|
||||
},
|
||||
name: PLUGIN_NAME,
|
||||
postRoutes: [
|
||||
{
|
||||
path: route,
|
||||
handler: async (req, res, next) => {
|
||||
const { allowedMimeTypes, directory, imageFit, imageHeight, imageWidth, route, ...otherProps } = Object.assign({}, defaults, config?.plugins?.uploadConfiguration ?? {});
|
||||
if (Array.isArray(allowedMimeTypes) && !allowedMimeTypes.length) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
debug('using configuration: ', {
|
||||
uploadConfiguration: config?.plugins?.uploadConfiguration,
|
||||
});
|
||||
const form = formidable({
|
||||
uploadDir: directory,
|
||||
...otherProps,
|
||||
});
|
||||
form.parse(req, async (err, fields, files) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
const uploadsDir = (route ?? '').replaceAll('/', '');
|
||||
let hadFileError = false;
|
||||
const processedFiles = [];
|
||||
const destinationByMimeType = getDestinationByMimeTypeConfiguration(allowedMimeTypes);
|
||||
debug(`processing ${(files?.files ?? []).length} files`);
|
||||
for (const file of files?.files ?? []) {
|
||||
const { filepath, hash, mimetype, newFilename, originalFilename, } = file ?? {};
|
||||
if (!(filepath && newFilename && originalFilename))
|
||||
return;
|
||||
debug({ file });
|
||||
let ext = extname(originalFilename).toLocaleLowerCase();
|
||||
if (ext === '.jpeg')
|
||||
ext = '.jpg';
|
||||
let destinationDir = '';
|
||||
Object.keys(destinationByMimeType).forEach((mimeType) => {
|
||||
if (file.mimetype?.split('/')?.[0] ===
|
||||
mimeType.toLocaleLowerCase())
|
||||
destinationDir =
|
||||
destinationByMimeType[mimeType].directory ?? '';
|
||||
});
|
||||
if (destinationDir === '')
|
||||
destinationDir =
|
||||
destinationByMimeType['*']?.directory ?? uploadsDir;
|
||||
const finalPath = join(process.cwd(), 'public', destinationDir);
|
||||
const useOriginalName = !(mimetype?.includes('image') || mimetype?.includes('video'));
|
||||
let hashNameLength = 8;
|
||||
let finalFilename;
|
||||
let fileExists;
|
||||
do {
|
||||
finalFilename = (!useOriginalName && hash
|
||||
? `${hash.substring(0, hashNameLength)}${ext}`
|
||||
: originalFilename)
|
||||
.replace(/[^\w\.]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.toLocaleLowerCase();
|
||||
if (!useOriginalName) {
|
||||
try {
|
||||
await fsp.stat(join(finalPath, finalFilename));
|
||||
fileExists = true;
|
||||
hashNameLength++;
|
||||
}
|
||||
catch {
|
||||
fileExists = false;
|
||||
}
|
||||
}
|
||||
} while (!useOriginalName && fileExists);
|
||||
debug(`creating '${finalPath}'`);
|
||||
fsp.mkdir(finalPath, { recursive: true });
|
||||
const pathToOutputFile = join(finalPath, finalFilename);
|
||||
let wasRelocated = false;
|
||||
if (mimetype?.includes('image')) {
|
||||
// use sharp to shrink
|
||||
debug(`converting '${filepath}' to '/${pathToOutputFile}'`);
|
||||
try {
|
||||
await sharp(filepath)
|
||||
.autoOrient()
|
||||
// shrink to 1024 on biggest edge, do not enlarge
|
||||
.resize({
|
||||
fit: imageFit,
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFile(pathToOutputFile);
|
||||
/*
|
||||
await sharp(filepath)
|
||||
.metadata()
|
||||
.then(({ height, width }) =>
|
||||
sharp(filepath)
|
||||
// shrink to 1024 on biggest edge, do not enlarge
|
||||
.resize({
|
||||
height: height >= width ? 1024 : undefined,
|
||||
width: width >= height ? 1024 : undefined,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFile(pathToOutputFile)
|
||||
);
|
||||
*/
|
||||
wasRelocated = true;
|
||||
}
|
||||
catch {
|
||||
// at least we tried
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!wasRelocated) {
|
||||
debug(`copying '${filepath}' to '/${pathToOutputFile}'`);
|
||||
await fsp.copyFile(filepath, pathToOutputFile);
|
||||
}
|
||||
debug(`cleaning up '${filepath}'`);
|
||||
await fsp.rm(filepath);
|
||||
debug(`processed successfully`);
|
||||
processedFiles.push(`/${destinationDir}/${finalFilename}`);
|
||||
}
|
||||
catch (err) {
|
||||
debug(`error!`);
|
||||
hadFileError = true;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
debug('generating reply...');
|
||||
if (hadFileError && processedFiles.length) {
|
||||
res
|
||||
.type('text/plain')
|
||||
.status(206)
|
||||
.send(processedFiles.join('\n'));
|
||||
return;
|
||||
}
|
||||
if (!processedFiles.length) {
|
||||
res.type('text/plain').status(500).send('No files processed');
|
||||
return;
|
||||
}
|
||||
res.type('text/plain').status(201).send(processedFiles.join('\n'));
|
||||
});
|
||||
},
|
||||
requiresAuth: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=plugin.js.map
|
||||
1
dist/src/uploadButton/plugin.js.map
vendored
Normal file
1
dist/src/uploadButton/plugin.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
13
dist/src/uploadButton/types.d.ts
vendored
Normal file
13
dist/src/uploadButton/types.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { MimeOptions } from 'express-twtkpr';
|
||||
import formidable from 'formidable';
|
||||
export interface MimeImageOptions extends MimeOptions {
|
||||
compression?: string;
|
||||
maxHeight?: string;
|
||||
maxWidth?: string;
|
||||
}
|
||||
export interface UploadConfiguration extends Partial<Omit<formidable.Options, 'uploadDir'>> {
|
||||
active: boolean;
|
||||
directory: string;
|
||||
allowedMimeTypes: string | string[] | Record<string, MimeOptions>;
|
||||
route: string;
|
||||
}
|
||||
2
dist/src/uploadButton/types.js
vendored
Normal file
2
dist/src/uploadButton/types.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
1
dist/src/uploadButton/types.js.map
vendored
Normal file
1
dist/src/uploadButton/types.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/uploadButton/types.ts"],"names":[],"mappings":""}
|
||||
7
dist/src/uploadButton/utils.d.ts
vendored
Normal file
7
dist/src/uploadButton/utils.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { MimeOptions } from 'express-twtkpr';
|
||||
/**
|
||||
*
|
||||
* @param allowedMimeTypes
|
||||
* @returns
|
||||
*/
|
||||
export declare const getDestinationByMimeTypeConfiguration: (allowedMimeTypes?: string | string[] | Record<string, MimeOptions>) => Record<string, MimeOptions>;
|
||||
48
dist/src/uploadButton/utils.js
vendored
Normal file
48
dist/src/uploadButton/utils.js
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
*
|
||||
* @param allowedMimeTypes
|
||||
* @returns
|
||||
*/
|
||||
export const getDestinationByMimeTypeConfiguration = (allowedMimeTypes) => {
|
||||
const fallback = {
|
||||
audio: {
|
||||
directory: 'audio',
|
||||
rename: false,
|
||||
},
|
||||
image: {
|
||||
directory: 'images',
|
||||
rename: true,
|
||||
},
|
||||
video: {
|
||||
directory: 'videos',
|
||||
rename: true,
|
||||
},
|
||||
'*': {
|
||||
directory: 'files',
|
||||
rename: false,
|
||||
},
|
||||
};
|
||||
const mimeTypeArrayReducer = (acc, curr) => {
|
||||
if (fallback[curr])
|
||||
acc[curr] = fallback[curr];
|
||||
else
|
||||
acc[curr] = {
|
||||
directory: `${curr}s`,
|
||||
rename: false,
|
||||
};
|
||||
return acc;
|
||||
};
|
||||
if (!allowedMimeTypes)
|
||||
return fallback;
|
||||
if (typeof allowedMimeTypes === 'string')
|
||||
return allowedMimeTypes
|
||||
.split(',')
|
||||
.map((val) => val.trim())
|
||||
.reduce(mimeTypeArrayReducer, {});
|
||||
if (Array.isArray(allowedMimeTypes))
|
||||
return allowedMimeTypes.reduce(mimeTypeArrayReducer, {});
|
||||
if (typeof allowedMimeTypes === 'object')
|
||||
return allowedMimeTypes;
|
||||
return fallback;
|
||||
};
|
||||
//# sourceMappingURL=utils.js.map
|
||||
1
dist/src/uploadButton/utils.js.map
vendored
Normal file
1
dist/src/uploadButton/utils.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../../src/uploadButton/utils.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,MAAM,qCAAqC,GAAG,CACpD,gBAAkE,EACjE,EAAE;IACH,MAAM,QAAQ,GAAgC;QAC7C,KAAK,EAAE;YACN,SAAS,EAAE,OAAO;YAClB,MAAM,EAAE,KAAK;SACb;QACD,KAAK,EAAE;YACN,SAAS,EAAE,QAAQ;YACnB,MAAM,EAAE,IAAI;SACZ;QACD,KAAK,EAAE;YACN,SAAS,EAAE,QAAQ;YACnB,MAAM,EAAE,IAAI;SACZ;QACD,GAAG,EAAE;YACJ,SAAS,EAAE,OAAO;YAClB,MAAM,EAAE,KAAK;SACb;KACD,CAAC;IAEF,MAAM,oBAAoB,GAAG,CAC5B,GAAgC,EAChC,IAAY,EACX,EAAE;QACH,IAAI,QAAQ,CAAC,IAAI,CAAC;YAAE,GAAG,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;;YAE9C,GAAG,CAAC,IAAI,CAAC,GAAG;gBACX,SAAS,EAAE,GAAG,IAAI,GAAG;gBACrB,MAAM,EAAE,KAAK;aACb,CAAC;QAEH,OAAO,GAAG,CAAC;IACZ,CAAC,CAAC;IAEF,IAAI,CAAC,gBAAgB;QAAE,OAAO,QAAQ,CAAC;IAEvC,IAAI,OAAO,gBAAgB,KAAK,QAAQ;QACvC,OAAO,gBAAgB;aACrB,KAAK,CAAC,GAAG,CAAC;aACV,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;aACxB,MAAM,CAAC,oBAAoB,EAAE,EAAE,CAAC,CAAC;IAEpC,IAAI,KAAK,CAAC,OAAO,CAAC,gBAAgB,CAAC;QAClC,OAAQ,gBAA6B,CAAC,MAAM,CAAC,oBAAoB,EAAE,EAAE,CAAC,CAAC;IAExE,IAAI,OAAO,gBAAgB,KAAK,QAAQ;QAAE,OAAO,gBAAgB,CAAC;IAElE,OAAO,QAAQ,CAAC;AACjB,CAAC,CAAC"}
|
||||
42
eslint.config.js
Normal file
42
eslint.config.js
Normal file
@@ -0,0 +1,42 @@
|
||||
export default [
|
||||
{
|
||||
// Basic settings
|
||||
files: ['**/*.js', '**/*.ts'],
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2025,
|
||||
sourceType: 'module',
|
||||
},
|
||||
|
||||
// Core rules
|
||||
rules: {
|
||||
'no-unused-vars': 'error',
|
||||
'no-undef': 'error',
|
||||
'no-console': 'warn',
|
||||
|
||||
'node/no-deprecated-api': 'error',
|
||||
'node/no-missing-import': 'error',
|
||||
'performance/no-costly-loop': 'warn',
|
||||
'promise/prefer-await-to-then': 'error',
|
||||
'security/detect-eval-with-expression': 'error',
|
||||
'security/detect-non-literal-fs-filename': 'error',
|
||||
'security/detect-non-literal-regexp': 'error',
|
||||
'security/detect-object-injection': 'warn',
|
||||
'security/detect-possible-timing-attacks': 'warn',
|
||||
},
|
||||
|
||||
// Use recommended presets
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:node/recommended',
|
||||
'plugin:security/recommended',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.ts'],
|
||||
plugins: ['prettier'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
30
package-lock.json
generated
Normal file
30
package-lock.json
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "express-twtkpr-upload-button",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "express-twtkpr-upload-button",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
package.json
54
package.json
@@ -1,4 +1,54 @@
|
||||
{
|
||||
"name": "express-twtkpr-upload-button",
|
||||
"packageManager": "yarn@4.9.2"
|
||||
"name": "express-twtkpr-core-plugins",
|
||||
"version": "0.9.0",
|
||||
"description": "A library of plugins that enhance TwtKpr installs (made with the `express-twtkpr` package).",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Eric Woodward",
|
||||
"email": "hey@itsericwoodward.com",
|
||||
"url": "https://www.itsericwoodward.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.itsericwoodward.com/eric/express-twtkpr-core-plugins"
|
||||
},
|
||||
"packageManager": "yarn@4.14.1",
|
||||
"type": "module",
|
||||
"main": "./dist/src/index.js",
|
||||
"module": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/src/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && rsync -avm --include '*/' --include '*/client/*.*' --exclude '*' src/ dist/src",
|
||||
"lint": "eslint --fix src test",
|
||||
"prepublishOnly": "yarn build",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"express": "^5.2.1",
|
||||
"express-twtkpr": "^0.9.0",
|
||||
"formidable": "^3.5.4",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/formidable": "^3.5.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
4
src/constants.ts
Normal file
4
src/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
114
src/emojiButton/client/emoji.css
Normal file
114
src/emojiButton/client/emoji.css
Normal file
@@ -0,0 +1,114 @@
|
||||
/* Copyright © 2020 Jamie Zawinski <jwz@dnalounge.com>
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell this software
|
||||
and its documentation for any purpose is hereby granted without
|
||||
fee, provided that the above copyright notice appear in all copies
|
||||
and that both that copyright notice and this permission notice
|
||||
appear in supporting documentation. No representations are made
|
||||
about the suitability of this software for any purpose. It is
|
||||
provided "as is" without express or implied warranty.
|
||||
|
||||
Emoji popup menu. There are many like it. This one is mine.
|
||||
|
||||
Created: 24-May-2020
|
||||
*/
|
||||
|
||||
#emoji_blocker {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.emoji_menu {
|
||||
position: fixed;
|
||||
display: inline-block;
|
||||
border: 1px solid;
|
||||
width: 22em;
|
||||
text-align: center;
|
||||
z-index: 1001;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.emoji_tab_bar {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji_tab {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1.8em;
|
||||
height: 1.3em;
|
||||
padding: 0px 2px 8px 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji_tab.selected {
|
||||
background: #040;
|
||||
}
|
||||
|
||||
.emoji_page_box {
|
||||
height: 16em;
|
||||
overflow-y: auto;
|
||||
background: #040;
|
||||
}
|
||||
|
||||
.emoji_page {
|
||||
display: none;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emoji_page > b {
|
||||
display: block;
|
||||
font-size: smaller;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.emoji_square {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
margin: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji_button {
|
||||
display: inline-block;
|
||||
font-size: smaller;
|
||||
padding: 0 4px 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Light modifications by Eric Woodward<hey@itsericwoodward,com> */
|
||||
|
||||
.emoji_button {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.emoji_menu {
|
||||
background: rgb(10, 10, 20, 0.6);
|
||||
}
|
||||
|
||||
.emoji_tab.selected {
|
||||
background: rgb(27, 27, 39);
|
||||
}
|
||||
|
||||
.emoji_page_box {
|
||||
background: rgb(27, 27, 39);
|
||||
}
|
||||
|
||||
.twtControls-contentInput {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.twtControls-contentLabel {
|
||||
position: relative;
|
||||
}
|
||||
2160
src/emojiButton/client/emoji.js
Normal file
2160
src/emojiButton/client/emoji.js
Normal file
File diff suppressed because it is too large
Load Diff
1
src/emojiButton/index.ts
Normal file
1
src/emojiButton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './plugin.js';
|
||||
47
src/emojiButton/plugin.ts
Normal file
47
src/emojiButton/plugin.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import Debug from 'debug';
|
||||
import type { TwtKprPluginConfiguration } from 'express-twtkpr';
|
||||
import { getReadStream } from 'express-twtkpr';
|
||||
|
||||
import { __dirname } from '../constants.js';
|
||||
|
||||
console.log({ __dirname });
|
||||
|
||||
const PLUGIN_NAME = 'emojiButton';
|
||||
|
||||
export default (): TwtKprPluginConfiguration => ({
|
||||
clientCSS: () => {
|
||||
const cssStream = fs.createReadStream(
|
||||
join(__dirname, 'src', PLUGIN_NAME, 'client', 'emoji.css')
|
||||
);
|
||||
|
||||
cssStream.on('error', (err) => {
|
||||
console.error(err);
|
||||
cssStream.close();
|
||||
cssStream.push(null);
|
||||
});
|
||||
|
||||
return cssStream;
|
||||
},
|
||||
|
||||
clientJS: () => {
|
||||
const jsFileStream = getReadStream(
|
||||
join(__dirname, 'src', PLUGIN_NAME, 'client', 'emoji.js')
|
||||
);
|
||||
/*
|
||||
const jsStream = Readable.from(`
|
||||
const twtSubmitButton = document.querySelector('.twtControls-submitButton');
|
||||
const emojiTarget = document.querySelector('.emoji_target');
|
||||
emojiTarget.addEventListener('change', () => {
|
||||
if (twtSubmitButton) twtSubmitButton.removeAttribute('disabled');
|
||||
});
|
||||
`);
|
||||
*/
|
||||
|
||||
return jsFileStream;
|
||||
},
|
||||
|
||||
name: PLUGIN_NAME,
|
||||
});
|
||||
3
src/index.ts
Normal file
3
src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as emojiButton } from './emojiButton/index.js';
|
||||
export { default as postToMastodon } from './postToMastodon/index.js';
|
||||
export { default as uploadButton } from './uploadButton/index.js';
|
||||
34
src/postToMastodon/client/script.js
Normal file
34
src/postToMastodon/client/script.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
|
||||
(() => {
|
||||
const twtForm = document.getElementById('twtForm');
|
||||
|
||||
/*
|
||||
document
|
||||
.querySelector('.twtControls-contentLabel')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton());
|
||||
|
||||
document
|
||||
.querySelector('#twtControlsEditButton')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton('small'));
|
||||
|
||||
const twtControlsContentInput = document.getElementById(
|
||||
'twtControlsContentInput'
|
||||
);
|
||||
|
||||
twtControlsContentInput.addEventListener('drop', dropHandler);
|
||||
|
||||
twtControlsContentInput.addEventListener('dragover', dragOverHandler);
|
||||
|
||||
window.addEventListener('dragover', dragOverWindowHandler);
|
||||
|
||||
window.addEventListener('drop', dropWindowHandler);
|
||||
|
||||
Array.from(document.querySelectorAll('.twtControls-uploadInput')).forEach(
|
||||
(uploadInput) => {
|
||||
uploadInput.addEventListener('change', uploadChangeHandler);
|
||||
}
|
||||
);
|
||||
*/
|
||||
})();
|
||||
// @license-end
|
||||
1
src/postToMastodon/index.ts
Normal file
1
src/postToMastodon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './plugin.js';
|
||||
88
src/postToMastodon/plugin.ts
Normal file
88
src/postToMastodon/plugin.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
TwtKprConfiguration,
|
||||
TwtKprPluginConfiguration,
|
||||
} from 'express-twtkpr';
|
||||
|
||||
import Debug from 'debug';
|
||||
|
||||
import { __dirname } from '../constants.js';
|
||||
|
||||
const PLUGIN_NAME = 'postToMastodon';
|
||||
|
||||
// curl https://toot.cafe/api/v1/statuses -H 'Authorization: Bearer A0k6vbobTQvG-n9DStsfGV03BKxKkZHL-IhljR3Lvik' -F 'status=Test posting from cURL via the API. Hello from the command line!'
|
||||
|
||||
export default (config: TwtKprConfiguration): TwtKprPluginConfiguration => {
|
||||
const debug = Debug(`twtkprPlugin:${PLUGIN_NAME}`);
|
||||
|
||||
const { application_token, server_url } =
|
||||
config?.plugins?.[PLUGIN_NAME] ?? {};
|
||||
|
||||
if (!application_token || !server_url) return {};
|
||||
|
||||
return {
|
||||
onAfterTwt: async (twt) => {
|
||||
// TODO: add some console.log / error to output things that we can't return a message for
|
||||
// That way, it shows up _somewhere_
|
||||
|
||||
// Don't do anything if the twt is a reply (starts with a hash)
|
||||
const [, content] = twt.split(/\t/);
|
||||
if (content.match(/^\(#(\w+)\)/)) {
|
||||
// it's a reply, skip
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('status', content.replace(/\s*\u2028/g, '\n'));
|
||||
|
||||
debug(`Sending message to Mastodon instance at ${server_url}`);
|
||||
|
||||
const res = await fetch(
|
||||
`${server_url}${server_url.endsWith('/') ? '' : '/'}api/v1/statuses`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${application_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`Bad response (${res.status}) from Mastodon server ${
|
||||
server_url
|
||||
}: ${res.statusText}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await res.text();
|
||||
|
||||
console.log(`Twt sent to Mastodon server ${server_url}, response:`, {
|
||||
result,
|
||||
});
|
||||
},
|
||||
|
||||
// use JS to add a checkbox to the form
|
||||
// update the Twt post function to send the whole form - that will
|
||||
// allow you to add new things to it and they will be sent
|
||||
|
||||
/*
|
||||
clientJS: () => {
|
||||
const jsStream = fs.createReadStream(
|
||||
path.join(__dirname, 'plugins', PLUGIN_NAME, 'script.js')
|
||||
);
|
||||
|
||||
jsStream.on('error', (err) => {
|
||||
console.error(err);
|
||||
jsStream.close();
|
||||
jsStream.push(null);
|
||||
});
|
||||
|
||||
return jsStream;
|
||||
},
|
||||
*/
|
||||
|
||||
name: PLUGIN_NAME,
|
||||
};
|
||||
};
|
||||
154
src/uploadButton/client/script.js
Normal file
154
src/uploadButton/client/script.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt MIT
|
||||
const injectUploadButton = (route) => {
|
||||
// const route = '/files';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param uploadConfiguration
|
||||
* @param variant
|
||||
* @returns
|
||||
*/
|
||||
const renderUploadButton = (variant = 'normal') => `
|
||||
<label class="button twtControls-uploadInputLabel twtControls-uploadInputLabel-${variant}"
|
||||
for="twtControlsUploadInput-${variant}"
|
||||
>
|
||||
Upload${variant === 'normal' ? '<br />' : ' '}Files
|
||||
<input accept="*" class="twtControls-uploadInput"
|
||||
id="twtControlsUploadInput-${variant}"
|
||||
multiple type="file" />
|
||||
</label>
|
||||
`;
|
||||
|
||||
const uploadFiles = async (files, uploadRoute, secondAttempt = false) => {
|
||||
if (!uploadRoute || !window.token || !window.refreshToken) return;
|
||||
|
||||
debug('uploadFiles', token, files, uploadRoute, secondAttempt);
|
||||
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', files[i]);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(uploadRoute, {
|
||||
method: 'POST',
|
||||
type: 'upload',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`File${files.length !== 1 ? 's' : ''} uploaded`);
|
||||
|
||||
const filePath = await res.text();
|
||||
twtControlsContentInput.value += filePath
|
||||
.split('\n')
|
||||
.map((currFilePath) =>
|
||||
[
|
||||
' ',
|
||||
location.protocol,
|
||||
'//',
|
||||
location.hostname,
|
||||
location.protocol !== 'https:' && location.port !== 80
|
||||
? ':' + location.port
|
||||
: '',
|
||||
currFilePath,
|
||||
].join('')
|
||||
)
|
||||
.join('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!secondAttempt) {
|
||||
await refreshToken();
|
||||
return uploadFiles(files, uploadRoute, true);
|
||||
}
|
||||
|
||||
showToast(
|
||||
`Unable to upload image${files.length !== 1 ? 's' : ''} refresh token, please try again later.`,
|
||||
'error'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
/* Handlers */
|
||||
|
||||
const dragOverHandler = (ev) => {
|
||||
const files = [...ev.dataTransfer.items].filter(
|
||||
(item) => item.kind === 'file'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const dragOverWindowHandler = (ev) => {
|
||||
const files = [...ev.dataTransfer.items].filter(
|
||||
(item) => item.kind === 'file'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!twtControlsContentInput.contains(ev.target)) {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dropHandler = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const files = [...ev.dataTransfer.items]
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file);
|
||||
|
||||
debug('dropHandler', files);
|
||||
uploadFiles(files, route);
|
||||
};
|
||||
|
||||
const dropWindowHandler = (ev) => {
|
||||
if ([...ev.dataTransfer.items].some((item) => item.kind === 'file')) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const uploadChangeHandler = (ev) => {
|
||||
uploadFiles(ev.target.files, route);
|
||||
};
|
||||
|
||||
document
|
||||
.querySelector('.twtControls-contentLabel')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton());
|
||||
|
||||
document
|
||||
.querySelector('#twtControlsEditButton')
|
||||
?.insertAdjacentHTML('beforebegin', renderUploadButton('small'));
|
||||
|
||||
const twtControlsContentInput = document.getElementById(
|
||||
'twtControlsContentInput'
|
||||
);
|
||||
|
||||
twtControlsContentInput.addEventListener('drop', dropHandler);
|
||||
|
||||
twtControlsContentInput.addEventListener('dragover', dragOverHandler);
|
||||
|
||||
window.addEventListener('dragover', dragOverWindowHandler);
|
||||
|
||||
window.addEventListener('drop', dropWindowHandler);
|
||||
|
||||
Array.from(document.querySelectorAll('.twtControls-uploadInput')).forEach(
|
||||
(uploadInput) => {
|
||||
uploadInput.addEventListener('change', uploadChangeHandler);
|
||||
}
|
||||
);
|
||||
};
|
||||
// @license-end
|
||||
37
src/uploadButton/client/styles.css
Normal file
37
src/uploadButton/client/styles.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Begin TwtKpr UploadPlugin CSS
|
||||
*/
|
||||
.twtControls-uploadInputLabel {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
.twtControls-uploadInputLabel-normal {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
|
||||
.twtControls-uploadInputLabel-small {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel:hover {
|
||||
background-color: var(--bg-hl);
|
||||
color: var(--fg-hl);
|
||||
}
|
||||
|
||||
/*
|
||||
.twtControls-uploadInput {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
40
src/uploadButton/defaults.ts
Normal file
40
src/uploadButton/defaults.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
uploadConfiguration: {
|
||||
...uploadConfiguration,
|
||||
active: uploadActive,
|
||||
allowEmptyFiles,
|
||||
allowedMimeTypes: getDestinationByMimeTypeConfiguration(allowedMimeTypes),
|
||||
createDirsFromUploads,
|
||||
directory,
|
||||
encoding,
|
||||
fileWriteStreamHandler,
|
||||
filter,
|
||||
hashAlgorithm: hashAlgorithm as string | false | undefined,
|
||||
keepExtensions,
|
||||
maxFields,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
maxTotalFileSize,
|
||||
minFileSize,
|
||||
route,
|
||||
},
|
||||
*/
|
||||
|
||||
// falls back to formidable defaults where it can
|
||||
export default {
|
||||
// local values
|
||||
allowedMimeTypes: '',
|
||||
directory: 'public',
|
||||
imageFit: 'inside',
|
||||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
route: 'files',
|
||||
// uploadEncoding: 'utf-8',
|
||||
|
||||
// defaults for formidable
|
||||
// allowEmptyFiles: false, // same as formidable
|
||||
// encoding: 'utf-8', // same as formidable
|
||||
hashAlgorithm: 'sha256',
|
||||
keepExtensions: true, // TODO: verify this is necessary
|
||||
maxFiles: 10,
|
||||
};
|
||||
1
src/uploadButton/index.ts
Normal file
1
src/uploadButton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './plugin.js';
|
||||
237
src/uploadButton/plugin.ts
Normal file
237
src/uploadButton/plugin.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type {
|
||||
MimeOptions,
|
||||
TwtKprConfiguration,
|
||||
TwtKprPluginConfiguration,
|
||||
} from 'express-twtkpr';
|
||||
|
||||
import fs, { promises as fsp, fstat } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import Debug from 'debug';
|
||||
import { combineStreams, getReadStream } from 'express-twtkpr';
|
||||
import formidable from 'formidable';
|
||||
import sharp, { FitEnum } from 'sharp';
|
||||
|
||||
import { __dirname } from '../constants.js';
|
||||
import defaults from './defaults.js';
|
||||
import { getDestinationByMimeTypeConfiguration } from './utils.js';
|
||||
|
||||
const PLUGIN_NAME = 'uploadButton';
|
||||
|
||||
export default (config: TwtKprConfiguration): TwtKprPluginConfiguration => {
|
||||
const debug = Debug(`twtkprPlugin:${PLUGIN_NAME}}`);
|
||||
const { route = '/files' } = config?.plugins?.[PLUGIN_NAME] ?? {};
|
||||
|
||||
return {
|
||||
clientCSS: () => {
|
||||
const stream = getReadStream(
|
||||
join(__dirname, 'src', PLUGIN_NAME, 'client', 'styles.css')
|
||||
);
|
||||
return stream;
|
||||
},
|
||||
clientJS: () => {
|
||||
const jsStream = getReadStream(
|
||||
join(__dirname, 'src', PLUGIN_NAME, 'client', 'script.js')
|
||||
);
|
||||
const jsCallerStream = Readable.from(`injectUploadButton('${route}');`);
|
||||
|
||||
return combineStreams([jsStream, jsCallerStream]);
|
||||
},
|
||||
|
||||
name: PLUGIN_NAME,
|
||||
|
||||
postRoutes: [
|
||||
{
|
||||
path: route,
|
||||
handler: async (req: Request, res: Response, next: NextFunction) => {
|
||||
const {
|
||||
allowedMimeTypes,
|
||||
directory,
|
||||
imageFit,
|
||||
imageHeight,
|
||||
imageWidth,
|
||||
route,
|
||||
...otherProps
|
||||
} = Object.assign(
|
||||
{},
|
||||
defaults,
|
||||
config?.plugins?.uploadConfiguration ?? {}
|
||||
);
|
||||
|
||||
if (Array.isArray(allowedMimeTypes) && !allowedMimeTypes.length) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
debug('using configuration: ', {
|
||||
uploadConfiguration: config?.plugins?.uploadConfiguration,
|
||||
});
|
||||
|
||||
const form = formidable({
|
||||
uploadDir: directory,
|
||||
...otherProps,
|
||||
});
|
||||
|
||||
form.parse(req, async (err, fields, files) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
const uploadsDir = (route ?? '').replaceAll('/', '');
|
||||
|
||||
let hadFileError = false;
|
||||
const processedFiles: string[] = [];
|
||||
const destinationByMimeType =
|
||||
getDestinationByMimeTypeConfiguration(allowedMimeTypes);
|
||||
|
||||
debug(`processing ${(files?.files ?? []).length} files`);
|
||||
|
||||
for (const file of files?.files ?? []) {
|
||||
const {
|
||||
filepath,
|
||||
hash,
|
||||
mimetype,
|
||||
newFilename,
|
||||
originalFilename,
|
||||
} = file ?? {};
|
||||
if (!(filepath && newFilename && originalFilename)) return;
|
||||
|
||||
debug({ file });
|
||||
|
||||
let ext = extname(originalFilename).toLocaleLowerCase();
|
||||
if (ext === '.jpeg') ext = '.jpg';
|
||||
|
||||
let destinationDir = '';
|
||||
Object.keys(destinationByMimeType).forEach((mimeType) => {
|
||||
if (
|
||||
file.mimetype?.split('/')?.[0] ===
|
||||
mimeType.toLocaleLowerCase()
|
||||
)
|
||||
destinationDir =
|
||||
(
|
||||
destinationByMimeType[
|
||||
mimeType as keyof typeof destinationByMimeType
|
||||
] as MimeOptions
|
||||
).directory ?? '';
|
||||
});
|
||||
if (destinationDir === '')
|
||||
destinationDir =
|
||||
(
|
||||
destinationByMimeType[
|
||||
'*' as keyof typeof destinationByMimeType
|
||||
] as MimeOptions
|
||||
)?.directory ?? uploadsDir;
|
||||
|
||||
const finalPath = join(process.cwd(), 'public', destinationDir);
|
||||
|
||||
const useOriginalName = !(
|
||||
mimetype?.includes('image') || mimetype?.includes('video')
|
||||
);
|
||||
let hashNameLength = 8;
|
||||
let finalFilename;
|
||||
let fileExists;
|
||||
do {
|
||||
finalFilename = (
|
||||
!useOriginalName && hash
|
||||
? `${hash.substring(0, hashNameLength)}${ext}`
|
||||
: originalFilename
|
||||
)
|
||||
.replace(/[^\w\.]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.toLocaleLowerCase();
|
||||
|
||||
if (!useOriginalName) {
|
||||
try {
|
||||
await fsp.stat(join(finalPath, finalFilename));
|
||||
fileExists = true;
|
||||
hashNameLength++;
|
||||
} catch {
|
||||
fileExists = false;
|
||||
}
|
||||
}
|
||||
} while (!useOriginalName && fileExists);
|
||||
|
||||
debug(`creating '${finalPath}'`);
|
||||
fsp.mkdir(finalPath, { recursive: true });
|
||||
|
||||
const pathToOutputFile = join(finalPath, finalFilename);
|
||||
let wasRelocated = false;
|
||||
|
||||
if (mimetype?.includes('image')) {
|
||||
// use sharp to shrink
|
||||
debug(`converting '${filepath}' to '/${pathToOutputFile}'`);
|
||||
|
||||
try {
|
||||
await sharp(filepath)
|
||||
.autoOrient()
|
||||
// shrink to 1024 on biggest edge, do not enlarge
|
||||
.resize({
|
||||
fit: imageFit as keyof FitEnum | undefined,
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFile(pathToOutputFile);
|
||||
/*
|
||||
await sharp(filepath)
|
||||
.metadata()
|
||||
.then(({ height, width }) =>
|
||||
sharp(filepath)
|
||||
// shrink to 1024 on biggest edge, do not enlarge
|
||||
.resize({
|
||||
height: height >= width ? 1024 : undefined,
|
||||
width: width >= height ? 1024 : undefined,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFile(pathToOutputFile)
|
||||
);
|
||||
*/
|
||||
wasRelocated = true;
|
||||
} catch {
|
||||
// at least we tried
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!wasRelocated) {
|
||||
debug(`copying '${filepath}' to '/${pathToOutputFile}'`);
|
||||
|
||||
await fsp.copyFile(filepath, pathToOutputFile);
|
||||
}
|
||||
|
||||
debug(`cleaning up '${filepath}'`);
|
||||
await fsp.rm(filepath);
|
||||
|
||||
debug(`processed successfully`);
|
||||
processedFiles.push(`/${destinationDir}/${finalFilename}`);
|
||||
} catch (err) {
|
||||
debug(`error!`);
|
||||
hadFileError = true;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
debug('generating reply...');
|
||||
if (hadFileError && processedFiles.length) {
|
||||
res
|
||||
.type('text/plain')
|
||||
.status(206)
|
||||
.send(processedFiles.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!processedFiles.length) {
|
||||
res.type('text/plain').status(500).send('No files processed');
|
||||
return;
|
||||
}
|
||||
|
||||
res.type('text/plain').status(201).send(processedFiles.join('\n'));
|
||||
});
|
||||
},
|
||||
requiresAuth: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
18
src/uploadButton/types.ts
Normal file
18
src/uploadButton/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { MimeOptions } from 'express-twtkpr';
|
||||
|
||||
import formidable from 'formidable';
|
||||
|
||||
export interface MimeImageOptions extends MimeOptions {
|
||||
compression?: string;
|
||||
maxHeight?: string;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export interface UploadConfiguration extends Partial<
|
||||
Omit<formidable.Options, 'uploadDir'>
|
||||
> {
|
||||
active: boolean;
|
||||
directory: string;
|
||||
allowedMimeTypes: string | string[] | Record<string, MimeOptions>;
|
||||
route: string;
|
||||
}
|
||||
58
src/uploadButton/utils.ts
Normal file
58
src/uploadButton/utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { MimeOptions } from 'express-twtkpr';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param allowedMimeTypes
|
||||
* @returns
|
||||
*/
|
||||
export const getDestinationByMimeTypeConfiguration = (
|
||||
allowedMimeTypes?: string | string[] | Record<string, MimeOptions>
|
||||
) => {
|
||||
const fallback: Record<string, MimeOptions> = {
|
||||
audio: {
|
||||
directory: 'audio',
|
||||
rename: false,
|
||||
},
|
||||
image: {
|
||||
directory: 'images',
|
||||
rename: true,
|
||||
},
|
||||
video: {
|
||||
directory: 'videos',
|
||||
rename: true,
|
||||
},
|
||||
'*': {
|
||||
directory: 'files',
|
||||
rename: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mimeTypeArrayReducer = (
|
||||
acc: Record<string, MimeOptions>,
|
||||
curr: string
|
||||
) => {
|
||||
if (fallback[curr]) acc[curr] = fallback[curr];
|
||||
else
|
||||
acc[curr] = {
|
||||
directory: `${curr}s`,
|
||||
rename: false,
|
||||
};
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
if (!allowedMimeTypes) return fallback;
|
||||
|
||||
if (typeof allowedMimeTypes === 'string')
|
||||
return allowedMimeTypes
|
||||
.split(',')
|
||||
.map((val) => val.trim())
|
||||
.reduce(mimeTypeArrayReducer, {});
|
||||
|
||||
if (Array.isArray(allowedMimeTypes))
|
||||
return (allowedMimeTypes as string[]).reduce(mimeTypeArrayReducer, {});
|
||||
|
||||
if (typeof allowedMimeTypes === 'object') return allowedMimeTypes;
|
||||
|
||||
return fallback;
|
||||
};
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "nodenext",
|
||||
"noImplicitAny": true,
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "esnext"
|
||||
},
|
||||
"include": ["./package.json", "./types.d.ts", "src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "./vitest.setup.ts", "**/__tests__/*.*"]
|
||||
}
|
||||
Reference in New Issue
Block a user