Pārlūkot izejas kodu

WIP - Able to add feed

Alan Colon 6 gadi atpakaļ
vecāks
revīzija
c591b1a2ed
16 mainītis faili ar 300 papildinājumiem un 27 dzēšanām
  1. 26 12
      app/api.js
  2. 1 1
      app/confirm.vue
  3. 16 0
      app/dashboard.vue
  4. 85 0
      app/feed.vue
  5. 11 0
      app/home.vue
  6. 10 2
      app/main.js
  7. 9 2
      app/main.vue
  8. 31 0
      app/new-feed.vue
  9. 1 3
      app/signup.vue
  10. 1 0
      package.json
  11. 1 0
      server/db.js
  12. 21 0
      server/rss.js
  13. 4 4
      server/security.js
  14. 49 1
      server/server.js
  15. 3 0
      server/xml.js
  16. 31 2
      yarn.lock

+ 26 - 12
app/api.js

@@ -1,18 +1,32 @@
+const { jwt } = require('./security')
+
 const wrap = (fetch, options) => {
   const fn = async (url, body) => {
-    const headers = {
-      'Accepts': 'application/json'
+    try {
+      const headers = {
+        'Accepts': 'application/json'
+      }
+      if (body) headers['Content-Type'] = 'application/json'
+      const result = await fetch(url, {
+        ...options,
+        headers: {
+          ...headers,
+          ...(options && options.headers),
+          ...(jwt.token && {
+            'Authorization': `Bearer ${jwt.token}`
+          })
+        },
+        body: body && JSON.stringify(body)
+      })
+      const text = await result.text()
+      try {
+        return JSON.parse(text)
+      } catch (err) {
+        return {error: text}
+      }
+    } catch (error) {
+      return {error}
     }
-    if (body) headers['Content-Type'] = 'application/json'
-    const result = await fetch(url, {
-      ...options,
-      headers: {
-        ...headers,
-        ...(options && options.headers)
-      },
-      body: body && JSON.stringify(body)
-    })
-    return await result.json()
   }
   Object.defineProperties(fn, {
     get: { get: () => wrap(fetch, { ...options, method: 'GET' }) },

+ 1 - 1
app/confirm.vue

@@ -37,6 +37,6 @@ export default {
     </p>
     <v-text-field v-model="model.confirmKey" label="Enter your confirmation key" placeholder="###-###" />
     <v-btn @click="submit">Confirm Email</v-btn>
-    <v-alert v-model="error" type="error">{{error}}</v-alert>
+    <v-alert :value="true" v-if="error" type="error" dismissible>{{error}}</v-alert>
   </div>
 </template>

+ 16 - 0
app/dashboard.vue

@@ -0,0 +1,16 @@
+<script>
+export default {
+  
+}
+</script>
+<template>
+  <div>
+    <v-btn color="primary" to="/feeds/new">
+      Add an RSS feed
+    </v-btn>
+    <!-- <v-list two-line>
+      <template v-for="(feed, index) in feeds">
+      </template>
+    </v-list> -->
+  </div>
+</template>

+ 85 - 0
app/feed.vue

@@ -0,0 +1,85 @@
+<script>
+const api = require('./api')
+export default {
+  props: {
+    id: String
+  },
+  data() {
+    return {
+      confirming: false,
+      loaded: false,
+      error: null,
+      justConfirmed: false,
+      model: {
+        url: null,
+        name: null,
+        confirmKey: null,
+        confirmed: false
+      }
+    }
+  },
+  async beforeMount() {
+    try {
+      this.model = await api.get(`feeds/${this.$route.params.id}`)
+      this.loaded = true
+    } catch (err) {
+      this.error = err
+    }
+  },
+  methods: {
+    async confirm() {
+      try {
+        this.confirming = true
+        const result = await api.get(`feeds/${this.$route.params.id}/confirm`)
+        if (result.error) this.confirmError = result.error
+        else if (result.success) {
+          this.model.confirmed = true
+          this.justConfirmed = true
+        }
+      } catch (err) {
+        this.error = error
+      } finally {
+        this.confirming = false
+      }
+    }
+  }
+}
+</script>
+<template>
+  <div>
+    <v-alert :value="true" v-if="error" type="error" dismissible>{{error}}</v-alert>
+    <div v-if="loaded">
+      <h1>{{model.title || 'RSS Feed'}}</h1>
+      <v-text-field v-model="model.url" label="URL to rss feed" />
+      <v-card v-if="!model.confirmed">
+        <v-card-title primary-title>
+          <h2>Confirm feed ownership</h2>
+        </v-card-title>
+        <v-card-text>
+          <p>
+            Before we start archiving and serving your RSS feed, we need to verify it is yours.
+            Please temporarily copy this text anywhere in your RSS feed, and click "Confirm". Once we have verified the
+            RSS feed is yours, you can remove the text.
+          </p>
+          <p class="confirm-key">{{model.confirmKey}}</p>
+        </v-card-text>
+        <v-card-actions>
+          <v-btn color="success" :disabled="confirming" @click="confirm">
+            Confirm
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+      <v-alert v-model="justConfirmed" type="success" dismissible>
+        We have verified this is your feed. You may now remove the text.
+      </v-alert>
+    </div>
+  </div>
+</template>
+<style scoped>
+.confirm-key {
+  user-select: all;
+  font-family: monospace;
+  font-size: 14pt;
+  text-align: center;
+}
+</style>

+ 11 - 0
app/home.vue

@@ -1,3 +1,14 @@
+<script>
+const { jwt } = require('./security')
+export default {
+  created() {
+    if (jwt.token) {
+      this.$router.push('/dashboard')
+    }
+  }
+}
+</script>
+
 <template>
   <div>
     <div class="hero">

+ 10 - 2
app/main.js

@@ -25,7 +25,11 @@ import Vuetify, {
   VForm,
   VBtn,
   VAlert,
-  VLayout
+  VLayout,
+  VCard,
+  VCardTitle,
+  VCardText,
+  VCardActions
 } from 'vuetify/lib'
 import { Ripple } from 'vuetify/lib/directives'
 
@@ -53,7 +57,11 @@ Vue.use(Vuetify, {
     VTextField,
     VBtn,
     VAlert,
-    VLayout
+    VLayout,
+    VCard,
+    VCardTitle,
+    VCardText,
+    VCardActions
   },
   directives: {
     Ripple

+ 9 - 2
app/main.vue

@@ -6,13 +6,19 @@ import Home from './home.vue'
 import Signup from './signup.vue'
 import Signin from './signin.vue'
 import Confirm from './confirm.vue'
+import Dashboard from './dashboard.vue'
+import Feed from './feed.vue'
+import NewFeed from './new-feed.vue'
 
 const router = new VueRouter({
   routes: [
     { path: '', component: Home },
     { path: '/signup', component: Signup },
     { path: '/signin', component: Signin },
-    { path: '/confirm/:email', component: Confirm }
+    { path: '/confirm/:email', component: Confirm },
+    { path: '/dashboard', component: Dashboard },
+    { path: '/feeds/new', component: NewFeed },
+    { path: '/feeds/:id', component: Feed }
   ]
 })
 
@@ -33,7 +39,8 @@ export default {
   computed: {
     gravatar() {
       return jwt.token && gravatar.url(jwt.identity.email, {
-        s: 24
+        size: 24,
+        default: 'retro'
       })
     },
     name() {

+ 31 - 0
app/new-feed.vue

@@ -0,0 +1,31 @@
+<script>
+const api = require('./api')
+export default {
+  data() {
+    return {
+      error: null,
+      model: {
+        url: null
+      }
+    }
+  },
+  methods: {
+    async submit() {
+      const result = await api.post('feeds', this.model)
+      if (result.error) {
+        this.error = result.error
+      } else {
+        this.$router.push(`/feeds/${result.id}`)
+      }
+    }
+  }
+}
+</script>
+<template>
+  <div>
+    <h1>New RSS Feed</h1>
+    <v-text-field v-model="model.url" label="URL to rss feed" />
+    <v-btn @click="submit">Continue...</v-btn>
+    <v-alert :value="true" v-if="error" type="error" dismissible>{{error}}</v-alert>
+  </div>
+</template>

+ 1 - 3
app/signup.vue

@@ -83,9 +83,7 @@ export default {
       <v-btn :disabled="!valid || saving" color="success" @click="submit">
         Create Account
       </v-btn>
-      <v-alert :value="!!error" type="error" :dismissible="true">
-        {{error}}
-      </v-alert>
+      <v-alert :value="true" v-if="error" type="error" dismissible>{{error}}</v-alert>
       
     </v-form>
   </div>

+ 1 - 0
package.json

@@ -50,6 +50,7 @@
     "level": "^5.0.1",
     "material-icons": "^0.3.0",
     "morgan": "^1.9.1",
+    "request-promise-native": "^1.0.7",
     "sendmail": "^1.6.1",
     "stylus": "^0.54.5",
     "stylus-loader": "^3.0.2",

+ 1 - 0
server/db.js

@@ -4,6 +4,7 @@ const rootDb = level('rss-unlimited')
 const wrap = (db, prefix) => {
   const locals = {
     async get(key, ...args) {
+      if (!key) throw new Error(`Key cannot be empty: ${prefix}${key}`)
       try {
         return JSON.parse(await db.get(prefix + key, ...args))
       } catch (err) {

+ 21 - 0
server/rss.js

@@ -0,0 +1,21 @@
+const XML = require('./xml')
+const request = require('request-promise-native')
+
+const getRss = async url => {
+  // http://www.hellointernet.fm/podcast?format=rss
+  url = new URL(url)
+  if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported protocol: ' + url.protocol)
+  const xml = await request(url, {
+    headers: {
+      'Accept': 'application/xml;application/rss+xml',
+      'User-Agent': 'rssunlimited.com'
+    }
+  })
+  const data = XML.parse(xml)
+  if (!data.rss) throw new Error('Response not an RSS feed.')
+  return data
+}
+
+module.exports = {
+  getRss
+}

+ 4 - 4
server/security.js

@@ -31,10 +31,10 @@ const validate = token => jwt.verify(token, SECRET, { complete: true })
 
 const authorize = (...claims) => (req, res, next) => {
   const reg = /^Bearer (.*)$/.exec(req.headers.authorization)
-  let identity
-  if (reg && (identity = validate(token))) {
-    if (claims.every(claim => identity[claim])) {
-      req.identity = identity
+  let decoded
+  if (reg && (decoded = validate(reg[1]))) {
+    if (claims.every(claim => decoded[claim])) {
+      req.identity = decoded.payload
       next()
     } else {
       res.status(403).send('Access denied')

+ 49 - 1
server/server.js

@@ -3,6 +3,8 @@ const db = require('./db')
 const log = require('./log')
 const { hashPassword, genSeed, genKey, authorize, createToken } = require('./security')
 const { sendmail } = require('./mail')
+const uuid = require('uuid/v4')
+const { getRss } = require('./rss')
 
 app.post('/signup', async (req, res) => {
   if (!req.body.name) throw new Error('Missing name')
@@ -92,11 +94,57 @@ app.post('/renew', authorize(), async (req, res) => {
     user: req.identity
   })
   const identity = {...req.identity}
-  delete identity.eat
+  delete identity.exp
   delete identity.iat
   res.status(200).send({
     token: createToken(identity)
   })
 })
 
+app.post('/feeds', authorize(), async (req, res) => {
+  if (!req.body.url) throw new Error('Missing url')
+  const doc = await getRss(req.body.url)
+  let title
+  try {
+    title = doc.rss.channel[0].title[0]
+  } catch (err) {}
+  if (!title) throw new Error('RSS has no channel or title.')
+  const userFeeds = (await db.userFeeds.get(req.identity.email)) || []
+  const feed = {
+    id: uuid(),
+    url: req.body.url,
+    email: req.identity.email,
+    title,
+    confirmed: false,
+    confirmKey: genKey({format: '[###-###-###-###]' }),
+    created: Date.now()
+  }
+  userFeeds.push(feed.id)
+  await db.userFeeds.put(req.identity.email, userFeeds)
+  await db.feeds.put(feed.id, feed)
+  res.status('200').send({
+    id: feed.id
+  })
+})
+
+app.get('/feeds/:id', authorize(), async (req, res) => {
+  const feed = await db.feeds.get(req.params.id)
+  if (!feed) return res.status(404).send('Feed not found')
+  if (feed.email !== req.identity.email) return res.status(403).send('This feed does not belong to you.')
+  res.status(200).send(feed)
+})
+
+app.patch('/feeds/:id', authorize(), async (req, res) => {
+  const feed = await db.feeds.get(req.params.id)
+  if (!feed) return res.status(404).send('Feed not found')
+  if (feed.email !== req.identity.email) return res.status(403).send('This feed does not belong to you.')
+  const allowedKeys = new Set([
+    'title'
+  ])
+  Object.entries(req.body).forEach(([key, value]) => {
+    if (allowedKeys.has(key)) feed[key] = value
+  })
+  await db.feeds.put(feed.id, feed)
+  res.status(200).send(feed)
+})
 app.start()

+ 3 - 0
server/xml.js

@@ -1,3 +1,4 @@
+const xml2js = require('xml2js')
 const XML = {
   parse(string) {
     let result = null
@@ -13,3 +14,5 @@ const XML = {
     return builder.buildObject(doc)
   }
 }
+
+module.exports = XML

+ 31 - 2
yarn.lock

@@ -4687,7 +4687,7 @@ pseudomap@^1.0.2:
   resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
   integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
 
-psl@^1.1.24:
+psl@^1.1.24, psl@^1.1.28:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/psl/-/psl-1.2.0.tgz#df12b5b1b3a30f51c329eacbdef98f3a6e136dc6"
   integrity sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==
@@ -4734,7 +4734,7 @@ punycode@1.3.2:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
   integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=
 
-punycode@2.x.x, punycode@^2.1.0:
+punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
@@ -4921,6 +4921,22 @@ repeat-string@^1.6.1:
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
 
+request-promise-core@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.2.tgz#339f6aababcafdb31c799ff158700336301d3346"
+  integrity sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==
+  dependencies:
+    lodash "^4.17.11"
+
+request-promise-native@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.7.tgz#a49868a624bdea5069f1251d0a836e0d89aa2c59"
+  integrity sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w==
+  dependencies:
+    request-promise-core "1.1.2"
+    stealthy-require "^1.1.1"
+    tough-cookie "^2.3.3"
+
 request@^2.65.0, request@^2.83.0:
   version "2.88.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
@@ -5411,6 +5427,11 @@ static-extend@^0.1.1:
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
 
+stealthy-require@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
+  integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
+
 stream-browserify@^2.0.1:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"
@@ -5768,6 +5789,14 @@ toposort@^1.0.0:
   resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029"
   integrity sha1-LmhELZ9k7HILjMieZEOsbKqVACk=
 
+tough-cookie@^2.3.3:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
+  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
+  dependencies:
+    psl "^1.1.28"
+    punycode "^2.1.1"
+
 tough-cookie@~2.4.3:
   version "2.4.3"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"