Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2021-01-26 14:39:12 +01:00
commit 3a384a51bd
31 changed files with 3093 additions and 2574 deletions

5
.gitignore vendored
View File

@ -4,4 +4,7 @@ public
dist dist
yarn-error.log yarn-error.log
storage/tmp storage/tmp
storage/uploads storage/uploads
config/local.*
src/package.json

View File

@ -6,9 +6,10 @@ $secondaryForeground: $primaryForeground;
$backgroundColor: darken($primary, 4%); $backgroundColor: darken($primary, 4%);
$defaultTextColor: #ffffff; $defaultTextColor: #ffffff;
$headerBackground: darken($primary, 7.5%); $headerBackground: transparent;
$footerBackground: lighten($headerBackground, 1%); $headerContainer: true;
$panelBackground: lighten($headerBackground, 1%); $footerBackground: transparent;
$panelBackground: darken($backgroundColor, 3.2%);
$inputBackground: darken($panelBackground, 4%); $inputBackground: darken($panelBackground, 4%);
$info: #4499ff; $info: #4499ff;
@ -28,4 +29,5 @@ $errorText: darken($error, 30%);
$errorColor: desaturate($errorText, 50%); $errorColor: desaturate($errorText, 50%);
// Responsivity // Responsivity
$mobileThreshold: 632px; $mobileThreshold: 850px;
$desktopThreshold: 940px;

View File

@ -1,5 +1,6 @@
@import "vars"; @import "vars";
@import 'fonts'; @import 'fonts';
@import "responsivity_tools";
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -69,21 +70,31 @@ body {
body > header { body > header {
z-index: 50; z-index: 50;
display: flex; display: flex;
flex-direction: row; flex-direction: row-reverse;
justify-content: space-between; justify-content: space-between;
align-items: center;
$headerHeight: 64px; $headerHeight: 64px;
height: $headerHeight; height: $headerHeight;
line-height: $headerHeight; line-height: $headerHeight;
background-color: $headerBackground; background: $headerBackground;
@if $headerContainer {
@include container;
}
@media (max-width: $mobileThreshold) {
padding: 0;
}
.logo { .logo {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
padding: 0 24px 0 16px; padding: 0 16px 0 8px;
font-size: 32px; font-size: 24px;
color: $defaultTextColor; color: $defaultTextColor;
&:hover { &:hover {
@ -91,34 +102,48 @@ body > header {
} }
img { img {
width: $headerHeight; width: initial;
height: $headerHeight; height: calc(#{$headerHeight} - 16px);
margin-right: 16px; margin-right: 8px;
flex-shrink: 0;
} }
} }
nav { nav {
ul { > ul {
position: fixed;
z-index: -1;
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
transition: transform ease-out 150ms;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
margin: 0; margin: 0;
padding: 0; padding: $headerHeight 8px 8px;
font-size: 20px; font-size: 20px;
background: $panelBackground;
li { li {
position: relative; position: relative;
list-style: none; list-style: none;
margin-top: 8px;
a, button { a, button {
position: relative; position: relative;
height: 64px;
margin: 0; margin: 0;
padding: 0 24px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
height: auto;
padding: 8px;
border-radius: 3px;
&:hover, &:active { &:hover, &:active {
&:not(button) { &:not(button) {
@ -127,13 +152,40 @@ body > header {
} }
.feather { .feather {
--icon-size: 24px; --icon-size: 16px;
}
.tip {
position: static;
visibility: visible;
opacity: 1;
display: block;
height: auto;
margin-left: 8px;
padding: 0 0 0 4px;
transform: none;
font-size: 16px;
line-height: 16px;
color: inherit;
text-transform: uppercase;
font-weight: inherit;
background: transparent;
}
&:hover {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
} }
} }
button { button {
margin: 8px; margin: 0;
padding: 24px;
height: 32px; height: 32px;
.feather { .feather {
@ -158,114 +210,117 @@ body > header {
} }
.dropdown { .dropdown {
position: absolute; position: initial;
z-index: -1;
top: 100%;
right: 0;
white-space: nowrap;
background: $headerBackground;
border-radius: 0 0 3px 3px;
a {
padding: 0 8px;
}
}
&:hover .dropdown {
display: block; display: block;
padding-left: 0;
} }
} }
> li:not(:first-child) {
border-top: 1px solid transparentize($defaultTextColor, 0.8);
padding-top: 8px;
}
&.open {
transform: translateX(0%);
box-shadow: 0 0 5px darken($panelBackground, 20%);
}
} }
#menu-button { #menu-button {
display: none; position: fixed;
} top: 0;
} left: 0;
display: block;
margin: 0;
padding: 0 16px;
line-height: $headerHeight;
@media (max-width: $mobileThreshold) { cursor: pointer;
flex-direction: row-reverse; background: transparent;
border-radius: 0;
.logo { .feather {
padding: 0 16px 0 8px; --icon-size: 28px;
font-size: 24px; margin: 0 8px;
img {
margin-right: 8px;
} }
} }
nav { hr {
#menu-button { border: 0;
display: block; border-bottom: 1px solid $defaultTextColor;
margin: 0; opacity: 0.2;
padding: 0 16px;
line-height: $headerHeight;
cursor: pointer;
background: transparent;
border-radius: 0;
.feather {
--icon-size: 28px;
margin: 0 8px;
}
}
> ul {
flex-direction: column;
position: absolute;
z-index: 10;
left: 0;
transform: translateX(-100%);
transition: transform ease-out 150ms;
background-color: $headerBackground;
&.open {
transform: translateX(0%);
}
li {
a, button {
.tip {
display: block;
margin-left: 8px;
text-transform: inherit;
font-weight: inherit;
}
}
.dropdown {
position: initial;
display: block;
padding-left: 32px;
}
}
}
} }
} }
@media (min-width: $mobileThreshold) { @media (min-width: $mobileThreshold) {
nav ul li { flex-direction: row;
a, button, .button {
@include tip; nav {
#menu-button {
display: none;
} }
&:last-child { ul {
a, button, .button { position: static;
.tip { flex-direction: row;
left: unset; transform: none;
right: 4px; padding: 0;
transform: none; background: transparent;
li {
margin-top: 0;
margin-left: 8px;
&:last-child {
a, button, .button {
.tip {
left: unset;
right: 4px;
transform: none;
}
}
} }
.dropdown {
position: absolute;
z-index: -1;
top: 100%;
right: 0;
display: none;
padding: 8px;
white-space: nowrap;
background: $panelBackground;
border-radius: 0 0 3px 3px;
box-shadow: 0 2px 2px transparentize(darken($panelBackground, 20%), 0.75);
border-top: 4px solid lighten($panelBackground, 5%);
li {
margin-left: 0;
&:not(:first-child) {
margin-top: 8px;
}
}
}
&:hover .dropdown {
display: block;
}
}
> li:not(:first-child) {
border-top: 0;
padding-top: 0;
} }
} }
} }
} }
} }
footer { body > footer {
padding: 8px; padding: 8px;
margin-top: 8px; margin-top: 8px;
text-align: center; text-align: center;
@ -338,7 +393,7 @@ a {
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: lighten($secondary, 10%); color: lighten($secondary, 30%);
} }
.feather.feather-external-link { .feather.feather-external-link {
@ -584,6 +639,10 @@ button, .button {
&.warning { &.warning {
background-color: $warningColor; background-color: $warningColor;
&:hover {
background-color: lighten($warningColor, 10%);
}
} }
&.error, &.danger { &.error, &.danger {
@ -603,6 +662,35 @@ button, .button {
} }
} }
// ---
// --- Tables
// ---
td.actions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
form {
padding: 0;
display: inline;
}
button, .button {
margin: 0;
padding: 8px;
.feather {
margin-right: 0;
}
}
> *:not(:first-child) {
margin-left: 8px;
}
}
.data-table { .data-table {
width: 100%; width: 100%;
text-align: left; text-align: left;
@ -614,6 +702,7 @@ button, .button {
th { th {
border-bottom: 1px solid #39434a; border-bottom: 1px solid #39434a;
white-space: nowrap;
} }
tr:nth-child(even) { tr:nth-child(even) {
@ -633,6 +722,10 @@ button, .button {
} }
} }
// ---
// --- Breadcrumb widget
// ---
.breadcrumb { .breadcrumb {
list-style: none; list-style: none;
display: flex; display: flex;
@ -646,6 +739,7 @@ button, .button {
} }
} }
// --- // ---
// --- Layout helpers // --- Layout helpers
// --- // ---
@ -653,24 +747,6 @@ button, .button {
text-align: center; text-align: center;
} }
@mixin container {
width: $mobileThreshold;
padding: 0 16px;
@media (min-width: $mobileThreshold) {
margin: 0 auto;
}
@media (max-width: $mobileThreshold) {
width: 100%;
padding: 0 8px;
}
}
.container {
@include container;
}
.panel { .panel {
position: relative; position: relative;
margin: 16px 0 48px; margin: 16px 0 48px;
@ -692,10 +768,14 @@ button, .button {
} }
.sub-panel { .sub-panel {
margin: 32px -18px; margin: 32px 0;
padding: 1px 16px; padding: 1px 16px;
border: 2px solid lighten($panelBackground, 4%); border: 2px solid lighten($panelBackground, 4%);
border-radius: 5px; border-radius: 5px;
form > & {
margin: 32px -18px;
}
} }
@ -719,6 +799,10 @@ button, .button {
stroke-linejoin: miter; stroke-linejoin: miter;
fill: none; fill: none;
vertical-align: middle; vertical-align: middle;
h1 > &, h2 > &, h3 > & {
--icon-size: 24px;
}
} }
// --- // ---
@ -789,37 +873,7 @@ button, .button {
} }
.content { .content {
overflow: hidden; width: 0;
white-space: nowrap;
padding: 8px;
}
.copy-button {
margin: 0;
padding: 0;
border-radius: 0;
.feather {
--icon-size: 20px;
margin: 8px;
}
}
}
.copyable-text {
display: flex;
flex-direction: row;
margin: 8px;
background-color: darken($backgroundColor, 2%);
border-radius: 5px;
overflow: hidden;
.title {
padding: 8px;
}
.content {
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@ -869,3 +923,7 @@ button, .button {
background: $secondary; background: $secondary;
} }
} }
.table-col-grow {
width: 100%;
}

View File

@ -0,0 +1,19 @@
@import "vars";
@mixin container {
width: 100%;
padding: 0 8px;
@media (min-width: $mobileThreshold) {
margin: 0 auto;
padding: 0 16px;
}
@media (min-width: $desktopThreshold) {
width: $desktopThreshold;
}
}
.container {
@include container;
}

View File

@ -1,26 +1,26 @@
{ {
"name": "ily.li", "name": "ily.li",
"version": "0.5.5", "version": "0.6.0",
"description": "Self-hosted file pusher", "description": "Self-hosted file pusher",
"repository": "https://eternae.ink/arisu/ily.li", "repository": "https://eternae.ink/arisu/ily.li",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "dist/src/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"dist-webpack": "webpack --mode production", "test": "jest --verbose --runInBand",
"clean": "(test ! -d dist || rm -r dist)", "clean": "(test ! -d dist || rm -r dist)",
"prepareSources": "cp package.json src/",
"compile": "yarn clean && tsc", "compile": "yarn clean && tsc",
"build": "yarn compile && yarn dist-webpack", "build": "yarn prepareSources && yarn compile && webpack --mode production",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "dev": "yarn prepareSources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"",
"dev": "concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon\" \"webpack --watch --mode development\" \"maildev\"", "start": "yarn build && node",
"start": "yarn build && node dist/src/main.js", "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
"test": "jest --verbose --runInBand"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.5", "@babel/preset-env": "^7.9.5",
"@fortawesome/fontawesome-free": "^5.14.0", "@fortawesome/fontawesome-free": "^5.14.0",
"@types/config": "^0.0.36", "@types/config": "^0.0.38",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/express-session": "^1.17.0", "@types/express-session": "^1.17.0",
"@types/feather-icons": "^4.7.0", "@types/feather-icons": "^4.7.0",
@ -46,6 +46,7 @@
"imagemin-svgo": "^8.0.0", "imagemin-svgo": "^8.0.0",
"img-loader": "^3.0.1", "img-loader": "^3.0.1",
"jest": "^26.1.0", "jest": "^26.1.0",
"maildev": "^1.1.0",
"mini-css-extract-plugin": "^1.2.1", "mini-css-extract-plugin": "^1.2.1",
"node-sass": "^5.0.0", "node-sass": "^5.0.0",
"nodemon": "^2.0.3", "nodemon": "^2.0.3",
@ -61,6 +62,6 @@
"config": "^3.3.1", "config": "^3.3.1",
"express": "^4.17.1", "express": "^4.17.1",
"formidable": "^1.2.2", "formidable": "^1.2.2",
"swaf": "^0.22.5" "swaf": "^0.23.0"
} }
} }

View File

@ -15,34 +15,35 @@ import CsrfProtectionComponent from "swaf/components/CsrfProtectionComponent";
import WebSocketServerComponent from "swaf/components/WebSocketServerComponent"; import WebSocketServerComponent from "swaf/components/WebSocketServerComponent";
import AboutController from "./controllers/AboutController"; import AboutController from "./controllers/AboutController";
import AutoUpdateComponent from "swaf/components/AutoUpdateComponent"; import AutoUpdateComponent from "swaf/components/AutoUpdateComponent";
import AuthController from "./controllers/AuthController";
import MagicLinkWebSocketListener from "swaf/auth/magic_link/MagicLinkWebSocketListener"; import MagicLinkWebSocketListener from "swaf/auth/magic_link/MagicLinkWebSocketListener";
import MagicLinkController from "./controllers/MagicLinkController";
import MailController from "swaf/auth/MailController";
import FileController from "./controllers/FileController"; import FileController from "./controllers/FileController";
import CreateUsersAndUserEmailsTable from "swaf/auth/migrations/CreateUsersAndUserEmailsTable";
import CreateMagicLinksTable from "swaf/auth/migrations/CreateMagicLinksTable";
import CreateAuthTokensTable from "./migrations/CreateAuthTokensTable"; import CreateAuthTokensTable from "./migrations/CreateAuthTokensTable";
import AuthComponent from "swaf/auth/AuthComponent"; import AuthComponent from "swaf/auth/AuthComponent";
import AuthGuard from "swaf/auth/AuthGuard";
import MagicLink from "swaf/auth/models/MagicLink";
import AuthToken from "./models/AuthToken";
import {MagicLinkActionType} from "./controllers/MagicLinkActionType";
import {Request} from "express";
import CreateFilesTable from "./migrations/CreateFilesTable"; import CreateFilesTable from "./migrations/CreateFilesTable";
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField"; import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
import AddApprovedFieldToUsersTable from "swaf/auth/migrations/AddApprovedFieldToUsersTable";
import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable"; import CreateUrlRedirectsTable from "./migrations/CreateUrlRedirectsTable";
import AuthTokenController from "./controllers/AuthTokenController"; import AuthTokenController from "./controllers/AuthTokenController";
import URLRedirectController from "./controllers/URLRedirectController"; import URLRedirectController from "./controllers/URLRedirectController";
import LinkController from "./controllers/LinkController"; import LinkController from "./controllers/LinkController";
import BackendController from "swaf/helpers/BackendController"; import BackendController from "swaf/helpers/BackendController";
import RedirectBackComponent from "swaf/components/RedirectBackComponent";
import DummyMigration from "swaf/migrations/DummyMigration"; import DummyMigration from "swaf/migrations/DummyMigration";
import DropLegacyLogsTable from "swaf/migrations/DropLegacyLogsTable"; import DropLegacyLogsTable from "swaf/migrations/DropLegacyLogsTable";
import {Session} from "express-session"; import CreateUsersAndUserEmailsTableMigration from "swaf/auth/migrations/CreateUsersAndUserEmailsTableMigration";
import packageJson = require('../package.json'); import CreateMagicLinksTableMigration from "swaf/auth/magic_link/CreateMagicLinksTableMigration";
import FixUserMainEmailRelation from "swaf/auth/migrations/FixUserMainEmailRelation"; import AddApprovedFieldToUsersTableMigration from "swaf/auth/migrations/AddApprovedFieldToUsersTableMigration";
import PreviousUrlComponent from "swaf/components/PreviousUrlComponent";
import MagicLinkAuthMethod from "swaf/auth/magic_link/MagicLinkAuthMethod";
import {MAGIC_LINK_MAIL} from "swaf/Mails";
import PasswordAuthMethod from "swaf/auth/password/PasswordAuthMethod";
import MailController from "swaf/mail/MailController";
import AccountController from "swaf/auth/AccountController";
import AuthController from "swaf/auth/AuthController";
import MagicLinkController from "swaf/auth/magic_link/MagicLinkController";
import AddUsedToMagicLinksMigration from "swaf/auth/magic_link/AddUsedToMagicLinksMigration";
import MakeMagicLinksSessionNotUniqueMigration from "swaf/auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
import AddPasswordToUsersMigration from "swaf/auth/password/AddPasswordToUsersMigration";
import DropNameFromUsers from "swaf/auth/migrations/DropNameFromUsers";
import packageJson = require('./package.json');
export default class App extends Application { export default class App extends Application {
public constructor( public constructor(
@ -56,15 +57,19 @@ export default class App extends Application {
return [ return [
CreateMigrationsTable, CreateMigrationsTable,
DummyMigration, DummyMigration,
CreateUsersAndUserEmailsTable, CreateUsersAndUserEmailsTableMigration,
CreateMagicLinksTable, CreateMagicLinksTableMigration,
CreateAuthTokensTable, CreateAuthTokensTable,
CreateFilesTable, CreateFilesTable,
IncreaseFilesSizeField, IncreaseFilesSizeField,
AddApprovedFieldToUsersTable, AddApprovedFieldToUsersTableMigration,
CreateUrlRedirectsTable, CreateUrlRedirectsTable,
DropLegacyLogsTable, DropLegacyLogsTable,
FixUserMainEmailRelation, DummyMigration,
AddUsedToMagicLinksMigration,
MakeMagicLinksSessionNotUniqueMigration,
AddPasswordToUsersMigration,
DropNameFromUsers,
]; ];
} }
@ -75,13 +80,8 @@ export default class App extends Application {
} }
private registerComponents() { private registerComponents() {
const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent();
const expressAppComponent = new ExpressAppComponent(this.addr, this.port);
this.use(expressAppComponent);
// Base // Base
this.use(new ExpressAppComponent(this.addr, this.port));
this.use(new LogRequestsComponent()); this.use(new LogRequestsComponent());
// Static files // Static files
@ -90,43 +90,22 @@ export default class App extends Application {
// Dynamic views and routes // Dynamic views and routes
this.use(new NunjucksComponent()); this.use(new NunjucksComponent());
this.use(new RedirectBackComponent()); this.use(new PreviousUrlComponent());
// Maintenance // Maintenance
this.use(new MaintenanceComponent(this, () => { this.use(new MaintenanceComponent(this, () => {
return redisComponent.canServe() && mysqlComponent.canServe(); return this.as(RedisComponent).canServe() && this.as(MysqlComponent).canServe();
})); }));
this.use(new AutoUpdateComponent()); this.use(new AutoUpdateComponent());
// Services // Services
this.use(mysqlComponent); this.use(new MysqlComponent());
this.use(new MailComponent()); this.use(new MailComponent());
// Session // Session
this.use(redisComponent); this.use(new RedisComponent());
this.use(new SessionComponent(redisComponent)); this.use(new SessionComponent(this.as(RedisComponent)));
this.use(new AuthComponent(new class extends AuthGuard<MagicLink | AuthToken> { this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
public async getProofForSession(session: Session): Promise<MagicLink | AuthToken | null> {
return await MagicLink.bySessionId(
session.id,
[MagicLinkActionType.LOGIN, MagicLinkActionType.REGISTER],
);
}
public async getProofForRequest(req: Request): Promise<MagicLink | AuthToken | null> {
const authorization = req.header('Authorization');
if (authorization) {
const token = await AuthToken.select().where('secret', authorization).first();
if (token) {
token.use();
await token.save();
}
return token;
}
return await super.getProofForRequest(req);
}
}(this)));
// Utils // Utils
this.use(new FormHelperComponent()); this.use(new FormHelperComponent());
@ -135,7 +114,7 @@ export default class App extends Application {
this.use(new CsrfProtectionComponent()); this.use(new CsrfProtectionComponent());
// WebSocket server // WebSocket server
this.use(new WebSocketServerComponent(this, expressAppComponent, redisComponent)); this.use(new WebSocketServerComponent(this, this.as(ExpressAppComponent), this.as(RedisComponent)));
} }
private registerWebSocketListeners() { private registerWebSocketListeners() {
@ -147,12 +126,13 @@ export default class App extends Application {
this.use(new LinkController()); this.use(new LinkController());
// Priority // Priority
this.use(new MailController());
this.use(new AuthController()); this.use(new AuthController());
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener))); this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
this.use(new BackendController());
// Core functionality // Core
this.use(new MailController()); this.use(new AccountController());
this.use(new BackendController());
// Other functionality // Other functionality
this.use(new AuthTokenController()); this.use(new AuthTokenController());

View File

@ -1,13 +1,12 @@
import {cryptoRandomDictionary} from "swaf/Utils";
import config from "config"; import config from "config";
import FileModel from "./models/FileModel"; import FileModel from "./models/FileModel";
import {ServerError} from "swaf/HttpError"; import {ServerError} from "swaf/HttpError";
import {nanoid} from "nanoid";
const SLUG_DICTIONARY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export default async function generateSlug(tries: number): Promise<string> { export default async function generateSlug(tries: number): Promise<string> {
let i = 0; let i = 0;
do { do {
const slug = cryptoRandomDictionary(config.get<number>('newlyGeneratedSlugSize'), SLUG_DICTIONARY); const slug = nanoid(config.get<number>('newlyGeneratedSlugSize'));
if (!await FileModel.getBySlug(slug)) { if (!await FileModel.getBySlug(slug)) {
return slug; return slug;
} }

View File

@ -1,8 +0,0 @@
import MagicLinkAuthController from "swaf/auth/magic_link/MagicLinkAuthController";
import {MAGIC_LINK_MAIL} from "swaf/Mails";
export default class AuthController extends MagicLinkAuthController {
public constructor() {
super(MAGIC_LINK_MAIL);
}
}

View File

@ -18,7 +18,7 @@ export default class AuthTokenController extends Controller {
}); });
await authToken.save(); await authToken.save();
req.flash('success', 'Successfully created auth token.'); req.flash('success', 'Successfully created auth token.');
res.redirectBack(Controller.route('file-upload')); res.redirect(req.getPreviousUrl() || Controller.route('file-upload'));
} }
protected async postRevokeAuthToken(req: Request, res: Response): Promise<void> { protected async postRevokeAuthToken(req: Request, res: Response): Promise<void> {
@ -34,6 +34,6 @@ export default class AuthTokenController extends Controller {
await authToken.delete(); await authToken.delete();
req.flash('success', 'Successfully deleted auth token.'); req.flash('success', 'Successfully deleted auth token.');
res.redirectBack(Controller.route('file-upload')); res.redirect(req.getPreviousUrl() || Controller.route('file-upload'));
} }
} }

View File

@ -8,7 +8,7 @@ import * as fs from "fs";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
import {IncomingForm} from "formidable"; import {IncomingForm} from "formidable";
import generateSlug from "../SlugGenerator"; import generateSlug from "../SlugGenerator";
import {log} from "swaf/Logger"; import {logger} from "swaf/Logger";
import FileUploadMiddleware from "swaf/FileUploadMiddleware"; import FileUploadMiddleware from "swaf/FileUploadMiddleware";
@ -66,6 +66,9 @@ export default class FileController extends Controller {
} }
const upload = req.files['upload']; const upload = req.files['upload'];
if (Array.isArray(upload)) {
throw new BadRequestError('Uploading multiple files at once is unsupported.', 'Please only upload one file at a time.', req.url);
}
// TTL // TTL
let ttl = config.get<number>('default_file_ttl'); let ttl = config.get<number>('default_file_ttl');
@ -97,7 +100,7 @@ export default class FileController extends Controller {
html: () => { html: () => {
req.flash('success', 'Upload success!'); req.flash('success', 'Upload success!');
req.flash('url', file.getURL(domain)); req.flash('url', file.getURL(domain));
res.redirectBack('/'); res.redirect(Controller.route('file-manager'));
}, },
}); });
} }
@ -132,7 +135,7 @@ export default class FileController extends Controller {
text: () => res.send('success'), text: () => res.send('success'),
html: () => { html: () => {
req.flash('success', 'Successfully deleted file.'); req.flash('success', 'Successfully deleted file.');
res.redirectBack('/'); res.redirect(Controller.route('file-manager'));
}, },
}); });
} }
@ -140,7 +143,7 @@ export default class FileController extends Controller {
public static async deleteFile(file: FileModel): Promise<void> { public static async deleteFile(file: FileModel): Promise<void> {
fs.unlinkSync(file.getOrFail('storage_path')); fs.unlinkSync(file.getOrFail('storage_path'));
await file.delete(); await file.delete();
log.info('Deleted', file.storage_path, `(${file.real_name})`); logger.info('Deleted', file.storage_path, `(${file.real_name})`);
} }
} }

View File

@ -20,6 +20,6 @@ export default class HomeController extends Controller {
* This is to test and assert that swaf extended types are available * This is to test and assert that swaf extended types are available
*/ */
protected async goBack(req: Request, res: Response): Promise<void> { protected async goBack(req: Request, res: Response): Promise<void> {
res.redirectBack(); res.redirect(req.getPreviousUrl() || Controller.route('home'));
} }
} }

View File

@ -11,7 +11,7 @@ import FileController, {FileUploadFormMiddleware} from "./FileController";
import * as fs from "fs"; import * as fs from "fs";
import {encodeRFC5987ValueChars} from "../Utils"; import {encodeRFC5987ValueChars} from "../Utils";
import {promisify} from "util"; import {promisify} from "util";
import {log} from "swaf/Logger"; import {logger} from "swaf/Logger";
export default class LinkController extends Controller { export default class LinkController extends Controller {
public routes(): void { public routes(): void {
@ -44,7 +44,7 @@ export default class LinkController extends Controller {
// If file is bigger than max hotlink size, fallback to express download // If file is bigger than max hotlink size, fallback to express download
if (stats.size > config.get<number>('max_hotlink_size') * 1024 * 1024) { if (stats.size > config.get<number>('max_hotlink_size') * 1024 * 1024) {
log.info(`Fallback to express download for file of size ${stats.size}`); logger.info(`Fallback to express download for file of size ${stats.size}`);
return res.download(file.getOrFail('storage_path'), fileName); return res.download(file.getOrFail('storage_path'), fileName);
} }
@ -108,7 +108,7 @@ export default class LinkController extends Controller {
protected async domainFilter(req: Request, res: Response, next: NextFunction): Promise<void> { protected async domainFilter(req: Request, res: Response, next: NextFunction): Promise<void> {
if (req.hostname !== config.get('domain')) { if (req.hostname !== config.get('domain')) {
if (req.path === '/') return res.redirect(config.get<string>('base_url')); if (req.path === '/') return res.redirect(config.get<string>('public_url'));
throw new NotFoundHttpError('Page', req.url); throw new NotFoundHttpError('Page', req.url);
} }
next(); next();

View File

@ -1,33 +0,0 @@
import _MagicLinkController from "swaf/auth/magic_link/MagicLinkController";
import {Request, Response} from "express";
import Controller from "swaf/Controller";
import MagicLinkWebSocketListener from "swaf/auth/magic_link/MagicLinkWebSocketListener";
import MagicLink from "swaf/auth/models/MagicLink";
import AuthController from "./AuthController";
import {MagicLinkActionType} from "./MagicLinkActionType";
import App from "../App";
import AuthComponent from "swaf/auth/AuthComponent";
export default class MagicLinkController extends _MagicLinkController<App> {
public constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener<App>) {
super(magicLinkWebSocketListener);
}
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
switch (magicLink.action_type) {
case MagicLinkActionType.LOGIN:
case MagicLinkActionType.REGISTER: {
await AuthController.checkAndAuth(req, res, magicLink);
const proof = await this.getApp().as(AuthComponent).getAuthGuard().isAuthenticated(req.getSession());
const user = await proof?.getResource();
if (!res.headersSent && user) {
// Auth success
req.flash('success', `Authentication success. Welcome, ${user.name}!`);
res.redirect(req.query.redirect_uri?.toString() || Controller.route('home'));
}
break;
}
}
}
}

View File

@ -8,13 +8,13 @@ import AuthToken from "../models/AuthToken";
export default class URLRedirectController extends Controller { export default class URLRedirectController extends Controller {
public routes(): void { public routes(): void {
this.get('/url/shrink', this.getURLShrinker, 'url-shrinker', RequireAuthMiddleware); this.get('/url/shrink', this.getUrlShrinker, 'url-shrinker', RequireAuthMiddleware);
this.get('/url/shrink/script', this.downloadLinuxScript, 'url-linux-script'); this.get('/url/shrink/script', this.downloadLinuxScript, 'url-linux-script');
this.post('/url/shrink', this.addURLFrontend, 'shrink-url', RequireAuthMiddleware); this.post('/url/shrink', this.addUrlFrontend, 'shrink-url', RequireAuthMiddleware);
this.get('/urls/:page([0-9]+)?', this.getURLRedirectManager, 'url-manager', RequireAuthMiddleware); this.get('/urls/:page([0-9]+)?', this.getUrlRedirectManager, 'url-manager', RequireAuthMiddleware);
} }
protected async getURLShrinker(req: Request, res: Response): Promise<void> { protected async getUrlShrinker(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser(); const user = req.as(RequireAuthMiddleware).getUser();
const allowedDomains = config.get<string[]>('allowed_url_domains'); const allowedDomains = config.get<string[]>('allowed_url_domains');
res.render('url-shrinker', { res.render('url-shrinker', {
@ -28,14 +28,14 @@ export default class URLRedirectController extends Controller {
res.download('assets/files/shrink_url.sh', 'shrink_url.sh'); res.download('assets/files/shrink_url.sh', 'shrink_url.sh');
} }
protected async getURLRedirectManager(req: Request, res: Response): Promise<void> { protected async getUrlRedirectManager(req: Request, res: Response): Promise<void> {
const user = req.as(RequireAuthMiddleware).getUser(); const user = req.as(RequireAuthMiddleware).getUser();
res.render('url-manager', { res.render('url-manager', {
urls: await URLRedirect.paginateForUser(req, 100, user.getOrFail('id')), urls: await URLRedirect.paginateForUser(req, 100, user.getOrFail('id')),
}); });
} }
protected async addURLFrontend(req: Request, res: Response, next: NextFunction): Promise<void> { protected async addUrlFrontend(req: Request, res: Response, next: NextFunction): Promise<void> {
req.body.type = 'url'; req.body.type = 'url';
await URLRedirectController.addURL( await URLRedirectController.addURL(
req, req,
@ -75,7 +75,7 @@ export default class URLRedirectController extends Controller {
html: () => { html: () => {
req.flash('success', 'URL shrunk successfully!'); req.flash('success', 'URL shrunk successfully!');
req.flash('url', urlRedirect.getURL(domain)); req.flash('url', urlRedirect.getURL(domain));
res.redirectBack('/'); res.redirect(Controller.route('url-manager'));
}, },
}); });
} }

View File

@ -2,19 +2,19 @@ import {delimiter} from "path";
// Load config from specified path or default + swaf/config (default defaults) // Load config from specified path or default + swaf/config (default defaults)
process.env['NODE_CONFIG_DIR'] = process.env['NODE_CONFIG_DIR'] =
__dirname + '/../../node_modules/swaf/config/' __dirname + '/../node_modules/swaf/config/'
+ delimiter + delimiter
+ (process.env['NODE_CONFIG_DIR'] || __dirname + '/../../config/'); + (process.env['NODE_CONFIG_DIR'] || __dirname + '/../config/');
import {log} from "swaf/Logger"; import {logger} from "swaf/Logger";
import App from "./App"; import App from "./App";
import config from "config"; import config from "config";
(async () => { (async () => {
log.debug('Config path:', process.env['NODE_CONFIG_DIR']); logger.debug('Config path:', process.env['NODE_CONFIG_DIR']);
const app = new App(config.get<string>('listen_addr'), config.get<number>('port')); const app = new App(config.get<string>('listen_addr'), config.get<number>('port'));
await app.start(); await app.start();
})().catch(err => { })().catch(err => {
log.error(err); logger.error(err);
}); });

View File

@ -1,10 +1,9 @@
import {Connection} from "mysql";
import Migration from "swaf/db/Migration"; import Migration from "swaf/db/Migration";
import ModelFactory from "swaf/db/ModelFactory"; import ModelFactory from "swaf/db/ModelFactory";
import AuthToken from "../models/AuthToken"; import AuthToken from "../models/AuthToken";
export default class CreateAuthTokensTable extends Migration { export default class CreateAuthTokensTable extends Migration {
public async install(connection: Connection): Promise<void> { public async install(): Promise<void> {
await this.query(`CREATE TABLE auth_tokens await this.query(`CREATE TABLE auth_tokens
( (
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
@ -14,11 +13,11 @@ export default class CreateAuthTokensTable extends Migration {
used_at DATETIME NOT NULL DEFAULT NOW(), used_at DATETIME NOT NULL DEFAULT NOW(),
ttl INT UNSIGNED NOT NULL, ttl INT UNSIGNED NOT NULL,
PRIMARY KEY (id) PRIMARY KEY (id)
)`, connection); )`);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(): Promise<void> {
await this.query(`DROP TABLE IF EXISTS auth_tokens`, connection); await this.query(`DROP TABLE IF EXISTS auth_tokens`);
} }
public registerModels(): void { public registerModels(): void {

View File

@ -1,10 +1,9 @@
import {Connection} from "mysql";
import Migration from "swaf/db/Migration"; import Migration from "swaf/db/Migration";
import ModelFactory from "swaf/db/ModelFactory"; import ModelFactory from "swaf/db/ModelFactory";
import FileModel from "../models/FileModel"; import FileModel from "../models/FileModel";
export default class CreateFilesTable extends Migration { export default class CreateFilesTable extends Migration {
public async install(connection: Connection): Promise<void> { public async install(): Promise<void> {
await this.query(`CREATE TABLE files await this.query(`CREATE TABLE files
( (
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
@ -17,11 +16,11 @@ export default class CreateFilesTable extends Migration {
created_at DATETIME NOT NULL DEFAULT NOW(), created_at DATETIME NOT NULL DEFAULT NOW(),
ttl INT UNSIGNED NOT NULL, ttl INT UNSIGNED NOT NULL,
PRIMARY KEY (id) PRIMARY KEY (id)
)`, connection); )`);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(): Promise<void> {
await this.query(`DROP TABLE IF EXISTS files`, connection); await this.query(`DROP TABLE IF EXISTS files`);
} }
public registerModels(): void { public registerModels(): void {

View File

@ -1,10 +1,9 @@
import Migration from "swaf/db/Migration"; import Migration from "swaf/db/Migration";
import {Connection} from "mysql";
import ModelFactory from "swaf/db/ModelFactory"; import ModelFactory from "swaf/db/ModelFactory";
import URLRedirect from "../models/URLRedirect"; import URLRedirect from "../models/URLRedirect";
export default class CreateUrlRedirectsTable extends Migration { export default class CreateUrlRedirectsTable extends Migration {
public async install(connection: Connection): Promise<void> { public async install(): Promise<void> {
await this.query(`CREATE TABLE url_redirects await this.query(`CREATE TABLE url_redirects
( (
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
@ -13,11 +12,11 @@ export default class CreateUrlRedirectsTable extends Migration {
target_url VARCHAR(1745) NOT NULL, target_url VARCHAR(1745) NOT NULL,
created_at DATETIME NOT NULL DEFAULT NOW(), created_at DATETIME NOT NULL DEFAULT NOW(),
PRIMARY KEY (id) PRIMARY KEY (id)
)`, connection); )`);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(): Promise<void> {
await this.query(`DROP TABLE IF EXISTS url_redirects`, connection); await this.query(`DROP TABLE IF EXISTS url_redirects`);
} }
public registerModels(): void { public registerModels(): void {

View File

@ -1,12 +1,11 @@
import Migration from "swaf/db/Migration"; import Migration from "swaf/db/Migration";
import {Connection} from "mysql";
export default class IncreaseFilesSizeField extends Migration { export default class IncreaseFilesSizeField extends Migration {
public async install(connection: Connection): Promise<void> { public async install(): Promise<void> {
await this.query(`ALTER TABLE files MODIFY size BIGINT UNSIGNED`, connection); await this.query(`ALTER TABLE files MODIFY size BIGINT UNSIGNED`);
} }
public async rollback(connection: Connection): Promise<void> { public async rollback(): Promise<void> {
await this.query(`ALTER TABLE files MODIFY size INT UNSIGNED`, connection); await this.query(`ALTER TABLE files MODIFY size INT UNSIGNED`);
} }
} }

View File

@ -1,7 +1,7 @@
import Model from "swaf/db/Model"; import Model from "swaf/db/Model";
import AuthProof from "swaf/auth/AuthProof"; import AuthProof from "swaf/auth/AuthProof";
import User from "swaf/auth/models/User"; import User from "swaf/auth/models/User";
import {cryptoRandomDictionary} from "swaf/Utils"; import {nanoid} from "nanoid";
export default class AuthToken extends Model implements AuthProof<User> { export default class AuthToken extends Model implements AuthProof<User> {
public id?: number = undefined; public id?: number = undefined;
@ -19,7 +19,7 @@ export default class AuthToken extends Model implements AuthProof<User> {
protected async autoFill(): Promise<void> { protected async autoFill(): Promise<void> {
if (!this.secret) { if (!this.secret) {
this.secret = cryptoRandomDictionary(64, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'); this.secret = nanoid(64);
} }
} }

View File

@ -38,7 +38,7 @@ export default class FileModel extends Model {
this.setValidation('ttl').defined().min(0).max(4294967295); this.setValidation('ttl').defined().min(0).max(4294967295);
} }
public getURL(domain: string = config.get<string>('base_url')): string { public getURL(domain: string = config.get<string>('public_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', { return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-file', {
slug: this.getOrFail('slug'), slug: this.getOrFail('slug'),
}); });

View File

@ -30,7 +30,7 @@ export default class URLRedirect extends Model {
this.setValidation('target_url').defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i); this.setValidation('target_url').defined().maxLength(1745).regexp(/^https?:\/\/.{3,259}?\/?/i);
} }
public getURL(domain: string = config.get<string>('base_url')): string { public getURL(domain: string = config.get<string>('public_url')): string {
return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', { return (/^https?:\/\//.test(domain) ? '' : 'https://') + domain + Controller.route('get-url', {
slug: this.getOrFail('slug'), slug: this.getOrFail('slug'),
}); });

View File

@ -2,6 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "public/js", "outDir": "public/js",
"rootDir": "./assets",
"target": "ES6", "target": "ES6",
"strict": true, "strict": true,
"lib": [ "lib": [

View File

@ -3,6 +3,7 @@
"module": "CommonJS", "module": "CommonJS",
"esModuleInterop": true, "esModuleInterop": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "./src",
"target": "ES6", "target": "ES6",
"strict": true, "strict": true,
"lib": [ "lib": [

View File

@ -1,6 +1,6 @@
<div class="container"> <div class="container">
<section class="panel"> <section class="panel">
<h2>Setup a desktop utility</h2> <h2><i data-feather="tool"></i> Setup a desktop utility</h2>
<p>There may be a desktop client at some point. For now, if you're an advanced user, you can setup <p>There may be a desktop client at some point. For now, if you're an advanced user, you can setup
scripts/macros.</p> scripts/macros.</p>
@ -12,7 +12,7 @@
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th class="table-col-grow">Name</th>
<th>Download link</th> <th>Download link</th>
</tr> </tr>
</thead> </thead>
@ -114,46 +114,46 @@
<p>For examples with curl, please download and review the scripts above.</p> <p>For examples with curl, please download and review the scripts above.</p>
</section> </section>
</section> </section>
</div>
<section class="panel"> <section class="panel">
<h2>Auth tokens</h2> <h2><i data-feather="key"></i> Auth tokens</h2>
<form action="{{ route('generate-token') }}" method="POST"> <form action="{{ route('generate-token') }}" method="POST">
{{ macros.csrf(getCsrfToken) }} {{ macros.csrf(getCsrfToken) }}
<button type="submit"><i data-feather="plus"></i> Generate a new token</button> <button type="submit"><i data-feather="plus"></i> Generate a new token</button>
</form> </form>
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr>
<th>#</th>
<th>Secret</th>
<th>Created at</th>
<th>Last used at</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in auth_tokens %}
<tr> <tr>
<td>{{ token.id }}</td> <th>#</th>
<td> <th class="table-col-grow">Secret</th>
<div class="copyable-text"> <th>Created at</th>
<div class="content">{{ token.secret }}</div> <th>Last used at</th>
<button class="copy-button"><i data-feather="copy"></i></button> <th>Actions</th>
</div>
</td>
<td>{{ token.created_at.toISOString() }}</td>
<td>{{ token.used_at.toISOString() }}</td>
<td>
<form action="{{ route('revoke-token', token.id) }}" method="POST">
<button class="button danger"><i data-feather="trash"></i> Revoke</button>
</form>
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody>
</table> <tbody>
</section> {% for token in auth_tokens %}
<tr>
<td>{{ token.id }}</td>
<td>
<div class="copyable-text">
<div class="content">{{ token.secret }}</div>
<button class="copy-button"><i data-feather="copy"></i></button>
</div>
</td>
<td>{{ token.created_at.toISOString() }}</td>
<td>{{ token.used_at.toISOString() }}</td>
<td class="actions">
<form action="{{ route('revoke-token', token.id) }}" method="POST">
<button class="button danger"><i data-feather="trash"></i> <span class="tip">Revoke</span></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>

View File

@ -10,47 +10,49 @@
<h1>File manager</h1> <h1>File manager</h1>
<p>You're their manager, please be nice with them.</p> <p>You're their manager, please be nice with them.</p>
<section class="panel"> <div class="container">
<h2>File list</h2> <section class="panel">
<table class="data-table"> <h2><i data-feather="folder"></i> File list</h2>
<thead> <table class="data-table">
<tr> <thead>
<th>#</th>
<th>URL</th>
<th>Name</th>
<th>Size</th>
<th>Expires at</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr> <tr>
<td>{{ file.id }}</td> <th>#</th>
<td> <th class="table-col-grow">URL</th>
<div class="copyable-text"> <th>Name</th>
<a class="content" href="{{ file.getURL() }}" target="_blank">{{ file.getURL() }}</a> <th>Size</th>
<button class="copy-button"><i data-feather="copy"></i></button> <th>Expires at</th>
</div> <th>Actions</th>
</td>
<td>{{ file.real_name }}</td>
<td>{{ (file.size / (1024 * 1024)).toFixed(2) }}MB</td>
{% set expires_at = file.getExpirationDate() %}
<td>{% if expires_at %}{{ expires_at.toISOString() }}{% else %}Never{% endif %}</td>
<td>
{% if file.shouldBeDeleted() %}
Pending deletion
{% else %}
<form action="{{ route('delete-file-frontend', file.slug) }}" method="post">
{{ macros.csrf(getCsrfToken) }}
<button class="button danger"><i data-feather="trash"></i> Delete</button>
</form>
{% endif %}
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody>
</table> <tbody>
</section> {% for file in files %}
<tr>
<td>{{ file.id }}</td>
<td>
<div class="copyable-text">
<a class="content" href="{{ file.getURL() }}" target="_blank">{{ file.getURL() }}</a>
<button class="copy-button"><i data-feather="copy"></i></button>
</div>
</td>
<td><pre>{{ file.real_name }}</pre></td>
<td>{{ (file.size / (1024 * 1024)).toFixed(2) }}MB</td>
{% set expires_at = file.getExpirationDate() %}
<td>{% if expires_at %}{{ expires_at.toISOString() }}{% else %}Never{% endif %}</td>
<td class="actions">
{% if file.shouldBeDeleted() %}
Pending deletion
{% else %}
<form action="{{ route('delete-file-frontend', file.slug) }}" method="post">
{{ macros.csrf(getCsrfToken) }}
<button class="button danger"><i data-feather="trash"></i> <span class="tip">Delete</span></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock %} {% endblock %}

View File

@ -12,7 +12,7 @@
<div class="container"> <div class="container">
<section class="panel"> <section class="panel">
<h2>Upload a file</h2> <h2><i data-feather="upload"></i> Upload a file</h2>
<form action="{{ route('post-file-frontend') }}" method="POST" enctype="multipart/form-data" <form action="{{ route('post-file-frontend') }}" method="POST" enctype="multipart/form-data"
id="upload-form"> id="upload-form">

View File

@ -18,19 +18,30 @@
<ul id="main-menu"> <ul id="main-menu">
<li><a href="{{ route('about') }}"><i data-feather="info"></i> <span class="tip">About</span></a></li> <li><a href="{{ route('about') }}"><i data-feather="info"></i> <span class="tip">About</span></a></li>
{% if user %} {% if user %}
<li><a href="{{ route('file-manager') }}"><i data-feather="folder"></i> <span class="tip">File manager</span></a></li> <li><a href="{{ route('file-manager') }}"><i data-feather="folder"></i> <span class="tip">File manager</span></a>
<li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> <span class="tip">Upload file</span></a></li> <ul class="dropdown">
<li><a href="{{ route('url-manager') }}"><i data-feather="link"></i> <span class="tip">URL manager</span></a></li> <li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> <span class="tip">Upload file</span></a></li>
<li><a href="{{ route('url-shrinker') }}"><i data-feather="crosshair"></i> <span class="tip">Shrink URL</span></a></li> </ul>
</li>
<li><a href="{{ route('url-manager') }}"><i data-feather="link"></i> <span class="tip">URL manager</span></a>
<ul class="dropdown">
<li><a href="{{ route('url-shrinker') }}"><i data-feather="crosshair"></i> <span class="tip">Shrink URL</span></a></li>
</ul>
</li>
{% if user.is_admin %} {% if user.is_admin %}
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li> <li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
{% endif %} {% endif %}
<li> <li><a href="{{ route('account') }}"><i data-feather="user"></i> <span class="tip">{{ user.name | default('Account') }}</span></a>
<form action="{{ route('logout') }}?{{ querystring.stringify({redirect_uri: '/'}) }}" method="POST"> <ul class="dropdown">
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button> <li>
<hr>
<form action="{{ route('logout') }}?{{ querystring.stringify({redirect_uri: '/'}) }}" method="POST">
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
{{ macros.csrf(getCsrfToken) }} {{ macros.csrf(getCsrfToken) }}
</form> </form>
</li>
</ul>
</li> </li>
{% else %} {% else %}
<li><a href="{{ route('auth') }}"><i data-feather="user"></i> <span class="tip">Login / Register</span></a></li> <li><a href="{{ route('auth') }}"><i data-feather="user"></i> <span class="tip">Login / Register</span></a></li>

View File

@ -8,31 +8,33 @@
<h1>URL manager</h1> <h1>URL manager</h1>
<p>These are permanent.</p> <p>These are permanent.</p>
<section class="panel"> <div class="container">
<h2>URL list</h2> <section class="panel">
<table class="data-table"> <h2><i data-feather="link"></i> URL list</h2>
<thead> <table class="data-table">
<tr> <thead>
<th>#</th>
<th>URL</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{% for url in urls %}
<tr> <tr>
<td>{{ url.id }}</td> <th>#</th>
<td> <th class="table-col-grow">URL</th>
<div class="copyable-text"> <th>Target</th>
<a class="content" href="{{ url.getURL() }}" target="_blank">{{ url.getURL() }}</a>
<button class="copy-button"><i data-feather="copy"></i></button>
</div>
</td>
<td>{{ url.target_url }}</td>
</tr> </tr>
{% endfor %} </thead>
</tbody>
</table> <tbody>
</section> {% for url in urls %}
<tr>
<td>{{ url.id }}</td>
<td>
<div class="copyable-text">
<a class="content" href="{{ url.getURL() }}" target="_blank">{{ url.getURL() }}</a>
<button class="copy-button"><i data-feather="copy"></i></button>
</div>
</td>
<td><pre>{{ url.target_url }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock %} {% endblock %}

View File

@ -12,7 +12,7 @@
<div class="container"> <div class="container">
<section class="panel"> <section class="panel">
<h2>Shrink a URL</h2> <h2><i data-feather="crosshair"></i> Shrink a URL</h2>
<form action="{{ route('shrink-url') }}" method="POST" id="url-shrink-form"> <form action="{{ route('shrink-url') }}" method="POST" id="url-shrink-form">
{{ macros.field(_locals, 'text', 'target_url', '', 'Target URL', 'Only valid URLs starting with http:// or https://', validation_attributes='required') }} {{ macros.field(_locals, 'text', 'target_url', '', 'Target URL', 'Only valid URLs starting with http:// or https://', validation_attributes='required') }}

4774
yarn.lock

File diff suppressed because it is too large Load Diff