Allow Seance to be hosted on a subpath

Now, instead of having to host Seance right at example.com, you
can also host it underneath at example.com/seance by changing the
appropriate setting!
This commit is contained in:
Hippo 2021-07-25 21:39:08 +05:30
parent ab5ced7c6a
commit ae1aacd17c
7 changed files with 74 additions and 21 deletions

View file

@ -52,6 +52,9 @@ files, and for your Ghost API interface. The parameters to set are:
* `GHOST_URL` - URL of your Ghost installation * `GHOST_URL` - URL of your Ghost installation
* `GHOST_VERSION` - 'v2' or 'v3' depending on which version you're using * `GHOST_VERSION` - 'v2' or 'v3' depending on which version you're using
* `GHOST_ADMIN_KEY` - 'Admi API key for Ghost' * `GHOST_ADMIN_KEY` - 'Admi API key for Ghost'
* `BASE_PATH` - Subdomain to serve the website on (eg. `/seance`).
Default: `/`. If you're not planning to use the Seance server, then
you can safely ignore this option.
In case you're wondering about the WebDAV server: that's the setup we In case you're wondering about the WebDAV server: that's the setup we
use at Snipette. We'd like to eventually let you upload directly through use at Snipette. We'd like to eventually let you upload directly through
@ -103,7 +106,16 @@ file via the "Labs" section. This is required becaues Ghost doesn't
let you directly add users; it only lets you import them. let you directly add users; it only lets you import them.
Seance also attempts to fetch the Medium user's profile image and upload Seance also attempts to fetch the Medium user's profile image and upload
it via WebDAV. The JSON file will link to the WebDAV-uploaded image it via WebDAV. The JSON file will link to the WebDAV-uploaded image.
## Run the Seance Server
The server is mainly needed when Seance'ing draft Medium posts, because
Medium doesn't let you access those anonymously so you need to resort
to bookmarklets instead. But it also comes in handy as a nicer,
friendlier interface for sending out posts for those who don't want to
use the command-line. The engine is more or less the same; it's just
the interface that's different!
# credits # credits

20
cli.js
View file

@ -136,6 +136,26 @@ program.command('setup')
config.ghost.admin_key = res.admin_key config.ghost.admin_key = res.admin_key
console.log(`Right. So that's Ghost ${config.ghost.version} running at ${config.ghost.url} with key ${config.ghost.admin_key}`) console.log(`Right. So that's Ghost ${config.ghost.version} running at ${config.ghost.url} with key ${config.ghost.admin_key}`)
console.log('\n\nNow, a bit about the server.')
console.log(
'This server usually runs on the main path (eg. example.com/).' +
'If you want to host seance on a different subpath (eg. ' +
'example.com/seance) then please enter that last part of the' +
'string now (eg. /seance).'
)
res = await prompt.get([
{ name: 'base_path', default: config.basePath || '/' },
])
if (res.base_path == '/') {
console.log('Okay. Serving on the root path then :)')
} else {
config.basePath = res.base_path
console.log(
'Right, so we\'ll be serving at yoursite.com' +
`${config.basePath} then :)`
)
}
console.log( console.log(
'\n\nA final thing. Do you have a "scissors" or other image ' + '\n\nA final thing. Do you have a "scissors" or other image ' +
'used as a separator in your article? If so, enter the path ' + 'used as a separator in your article? If so, enter the path ' +

View file

@ -95,6 +95,13 @@ let config = convict({
env: 'SEPARATOR_IMAGE', env: 'SEPARATOR_IMAGE',
default: null, default: null,
}, },
basePath: {
doc: 'Base subpath on which Seance is hosted (eg. /seance)',
format: 'String', // TODO: validate by checking path
env: 'BASE_PATH',
default: null,
nullable: true,
},
}) })
// Load configs from home directory, if present // Load configs from home directory, if present

View file

@ -5,6 +5,7 @@ const slugify = require('underscore.string/slugify')
const fs = require('fs') const fs = require('fs')
const { Seance } = require ('./seance') const { Seance } = require ('./seance')
const config = require('./config')
const app = express() const app = express()
var expressWs = require('express-ws')(app) var expressWs = require('express-ws')(app)
@ -18,8 +19,12 @@ app.use(bodyParser.urlencoded({
app.use(bodyParser('json')) app.use(bodyParser('json'))
// Enable static files // Enable static files
app.use(express.static('public')) app.use('/', express.static('public')) // basePath is prefixed later
app.use(express.static('static')) app.use(config.basePath || '/', express.static('static'))
// Router
var router = express.Router()
app.use(config.basePath || '/', router)
// Set up VueXpress // Set up VueXpress
let options = { let options = {
@ -29,12 +34,12 @@ let options = {
metaInfo: { metaInfo: {
title: 'Seance', title: 'Seance',
script: [ script: [
{ type: 'text/javascript', src: '/app.js' }, { type: 'text/javascript', src: config.basePath + '/app.js' },
], ],
}, },
extractCSS: true, extractCSS: true,
cssOutputPath: '/css/styles.css', cssOutputPath: '/css/styles.css',
publicPath: 'public', publicPath: 'public' + config.basePath,
compilerConfig: { compilerConfig: {
// custom webpack config // custom webpack config
}, },
@ -46,19 +51,22 @@ let options = {
} }
const renderer = vueRenderer(options) const renderer = vueRenderer(options)
app.use(renderer) router.use(renderer)
// Views // Views
app.get('/', (req, res) => { router.get('/', (req, res) => {
res.render('index', { res.render('index', {
baseUrl: req.hostname.startsWith('localhost') seanceUrl: req.hostname.startsWith('localhost')
? req.protocol + '://' + req.headers.host ? req.headers.host + req.baseUrl
: '//' + req.hostname, // auto choose http or https : req.hostname + req.baseUrl,
protocol: req.hostname.startsWith('localhost')
? req.protocol + '://'
: '//',
}) })
}) })
app.post('/fetch', (req, res) => { router.post('/fetch', (req, res) => {
var json var json
var post var post
@ -110,6 +118,9 @@ app.post('/fetch', (req, res) => {
// render the final post // render the final post
res.render('fetch-medium', { res.render('fetch-medium', {
seanceUrl: req.hostname.startsWith('localhost')
? req.headers.host + req.baseUrl
: req.hostname + req.baseUrl,
post: { post: {
title: post.title, title: post.title,
subtitle: post.content.subtitle, subtitle: post.content.subtitle,
@ -125,14 +136,14 @@ app.get('/fetch', (req, res) => {
res.redirect(303, '/') res.redirect(303, '/')
}) })
app.get('/api', (req, res) => { router.get('/api', (req, res) => {
res.json({ res.json({
status: 'success', status: 'success',
message: 'Welcome to the Seance API :)', message: 'Welcome to the Seance API :)',
}) })
}) })
app.ws('/ws/fetch-medium', (ws, req) => { router.ws('/ws/fetch-medium', (ws, req) => {
ws.on('message', async(msg) => { ws.on('message', async(msg) => {
command = msg.split(' ') command = msg.split(' ')
@ -182,7 +193,7 @@ app.ws('/ws/fetch-medium', (ws, req) => {
}) })
}) })
app.ws('/ws/push-ghost', (ws, req) => { router.ws('/ws/push-ghost', (ws, req) => {
ws.on('message', async(msg) => { ws.on('message', async(msg) => {
// respond to keepalive // respond to keepalive

View file

@ -11,12 +11,13 @@ function addNotification(msg, className='is-warning') {
function mediumFetch(e) { function mediumFetch(e) {
if(e) { e.preventDefault() } if(e) { e.preventDefault() }
const baseUrl = document.querySelector('[data-seance-url]').dataset.seanceUrl
const postSlug = document.querySelector('[data-post-slug]').dataset.postSlug const postSlug = document.querySelector('[data-post-slug]').dataset.postSlug
const mediumUrl = document.querySelector('[data-post-mediumurl]').dataset.postMediumurl const mediumUrl = document.querySelector('[data-post-mediumurl]').dataset.postMediumurl
var password = document.querySelector('input[name=password]').value var password = document.querySelector('input[name=password]').value
console.log('Fetching ' + postSlug) console.log('Fetching ' + postSlug)
const socketProtocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:') const socketProtocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:')
const socketUrl = socketProtocol + '//' + window.location.host + '/ws/fetch-medium/' const socketUrl = socketProtocol + '//' + baseUrl + '/ws/fetch-medium/'
const socket = new WebSocket(socketUrl) const socket = new WebSocket(socketUrl)
// set up the div // set up the div

View file

@ -2,7 +2,7 @@
<div id="app"> <div id="app">
<nav class="navbar has-background-light" role="navigation" aria-label="main navigation"> <nav class="navbar has-background-light" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item is-size-5" href="/">Seance</a> <a class="navbar-item is-size-5" :href="seanceUrl + '/'">Seance</a>
<a role="button" class="navbar navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarMain"> <a role="button" class="navbar navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@ -44,7 +44,7 @@
</div> </div>
<footer class="card-footer" id="statusbar"> <footer class="card-footer" id="statusbar">
<a :href="post.mediumUrl" v-if="post.mediumUrl" class="card-footer-item">View on Medium</a> <a :href="post.mediumUrl" v-if="post.mediumUrl" class="card-footer-item">View on Medium</a>
<a href="#" class="card-footer-item" onclick="mediumFetch(event)" :data-post-slug="post.slug" :data-post-mediumurl="post.mediumUrl">Fetch</a> <a href="#" class="card-footer-item" onclick="mediumFetch(event)" :data-post-slug="post.slug" :data-post-mediumurl="post.mediumUrl" :data-seance-url="seanceUrl">Fetch</a>
<a href="#" class="card-footer-item" onclick="ghostPush(event)">Push to Ghost</a> <a href="#" class="card-footer-item" onclick="ghostPush(event)">Push to Ghost</a>
</footer> </footer>
</div> </div>
@ -62,6 +62,7 @@
title: 'Seance', title: 'Seance',
user: null, user: null,
host: 'http://localhost:4000', host: 'http://localhost:4000',
seanceUrl: 'http://localhost:4000',
post: { post: {
title: '(untitled)', title: '(untitled)',
subtitle: '', subtitle: '',

View file

@ -2,7 +2,7 @@
<div id="app"> <div id="app">
<nav class="navbar has-background-light" role="navigation" aria-label="main navigation"> <nav class="navbar has-background-light" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item is-size-5" href="/">Seance</a> <a class="navbar-item is-size-5" :href="'//' + seanceUrl + '/'">Seance</a>
<a role="button" class="navbar navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarMain"> <a role="button" class="navbar navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@ -27,7 +27,7 @@
<div class="column is-half is-offset-one-quarter has-text-centered" style="margin-top: 10%;"> <div class="column is-half is-offset-one-quarter has-text-centered" style="margin-top: 10%;">
<h1 class="title">{{ title }}</h1> <h1 class="title">{{ title }}</h1>
<form action="/fetch/medium/" method="post"> <form :action="'/' + seanceUrl + '/fetch/medium/'" method="post">
<input type="search" class="input is-medium is-rounded is-focused" placeholder="Enter a Medium link..."/> <input type="search" class="input is-medium is-rounded is-focused" placeholder="Enter a Medium link..."/>
<p>or, use the bookmarklet <a class="button is-primary is-small" :href="bookmarklet">Add to Seance</a></p> <p>or, use the bookmarklet <a class="button is-primary is-small" :href="bookmarklet">Add to Seance</a></p>
</form> </form>
@ -43,12 +43,13 @@
return { return {
title: 'Seance', title: 'Seance',
user: null, user: null,
baseUrl: 'http://localhost:4000', seanceUrl: 'localhost:4000',
protocol: '//',
} }
}, },
computed: { computed: {
bookmarklet () { bookmarklet () {
return `javascript:(()=>{document.title='[Loading...] '+document.title;s=document.createElement('script');s.src='${this.baseUrl}/bookmarklet.js';s.dataset.seanceUrl='${this.baseUrl}';document.body.appendChild(s)})()` return `javascript:(()=>{document.title='[Loading...] '+document.title;s=document.createElement('script');s.src='${this.protocol}${this.seanceUrl}/bookmarklet.js';s.dataset.seanceUrl='${this.protocol}${this.seanceUrl}';document.body.appendChild(s)})()`
} }
} }
} }