Browse Source

test(project): 72.9% coverage; 60% passing.

jackyalcine 11 months ago
parent
commit
7a8f26c657
Signed by: Jacky Alciné <yo@jacky.wtf> GPG Key ID: 36CD7728BDFD66FF
100 changed files with 3154 additions and 1757 deletions
  1. 138
    0
      .credo.exs
  2. 24
    0
      .drone.yml
  3. 3
    2
      .editorconfig
  4. 2
    2
      .formatter.exs
  5. 1
    1
      .lvimrc
  6. 1
    0
      .node-version
  7. 27
    3
      .projections.json
  8. 1
    1
      Dockerfile
  9. 13
    0
      INSTALL.markdown
  10. 8
    4
      README.markdown
  11. 1
    1
      docker/scripts/build.sh
  12. 0
    3
      docker/scripts/post-deploy.sh
  13. 3
    0
      docker/scripts/pre-deploy.sh
  14. 7
    1
      docker/scripts/prepare.sh
  15. 1
    3
      lib/application.ex
  16. 1
    1
      lib/cache.ex
  17. 18
    0
      lib/format.ex
  18. 33
    23
      lib/http.ex
  19. 4
    5
      lib/indieweb/app.ex
  20. 12
    15
      lib/indieweb/app/h_x_app.ex
  21. 20
    31
      lib/indieweb/app/link.ex
  22. 0
    60
      lib/indieweb/app/mf2.ex
  23. 3
    4
      lib/indieweb/app/opengraph.ex
  24. 3
    2
      lib/indieweb/app/schemaorg.ex
  25. 3
    2
      lib/indieweb/app/webmanifest.ex
  26. 1
    1
      lib/indieweb/auth/scope.ex
  27. 0
    130
      lib/indieweb/jf2.ex
  28. 1
    7
      lib/indieweb/mf2.ex
  29. 12
    14
      lib/indieweb/mf2/remote.ex
  30. 7
    5
      lib/indieweb/micropub.ex
  31. 5
    12
      lib/indieweb/micropub/content.ex
  32. 229
    143
      lib/indieweb/micropub/entry.ex
  33. 171
    178
      lib/indieweb/post.ex
  34. 12
    1
      lib/indieweb/relme.ex
  35. 23
    28
      lib/indieweb/webmention.ex
  36. 14
    13
      lib/page.ex
  37. 1
    12
      lib/profile.ex
  38. 10
    7
      lib/repo.ex
  39. 2
    3
      lib/repo/category.ex
  40. 71
    60
      lib/repo/entry.ex
  41. 3
    14
      lib/repo/relme.ex
  42. 1
    6
      lib/repo/setting.ex
  43. 10
    14
      lib/repo/webmention.ex
  44. 122
    159
      lib/storage.ex
  45. 43
    0
      lib/storage/image.ex
  46. 80
    0
      lib/storage/json.ex
  47. 0
    154
      lib/storage/object_data.ex
  48. 0
    82
      lib/storage/photo.ex
  49. 17
    7
      lib/storage/video.ex
  50. 24
    0
      lib/web.ex
  51. 6
    3
      mix.exs
  52. 1
    1
      mix.lock
  53. 0
    1
      priv/repo/migrations/20181218212555_create_webmentions.exs
  54. 1
    1
      priv/repo/migrations/20181218221941_send_only_one_webmention_per_source_and_target.exs
  55. 7
    0
      priv/repo/migrations/20181221203932_use_type_instead_of_post_type_in_entries.exs
  56. 9
    0
      priv/repo/migrations/20181221234809_add_deleted_at_to_entries.exs
  57. 9
    0
      priv/repo/migrations/20181221234937_add_deleted_at_to_webmentions.exs
  58. 0
    40
      test/fixtures/vcr_cassettes/attempts_successful_webmention_send.json
  59. 46
    0
      test/fixtures/vcr_cassettes/fails_to_fetch_due_to_network_error.json
  60. 0
    0
      test/fixtures/vcr_cassettes/fetch_force_refresh.json
  61. 47
    0
      test/fixtures/vcr_cassettes/fetch_from_page.json
  62. 0
    0
      test/fixtures/vcr_cassettes/fetches_cache_invalid.json
  63. 46
    0
      test/fixtures/vcr_cassettes/page_is_empty.json
  64. 49
    0
      test/fixtures/vcr_cassettes/refresh_of_mf2_data.json
  65. 0
    0
      test/fixtures/vcr_cassettes/relme_failing_endpoint.json
  66. 0
    0
      test/fixtures/vcr_cassettes/relme_redirecting_endpoint.json
  67. 0
    1
      test/fixtures/vcr_cassettes/sends_successfully.json
  68. 11
    4
      test/integration/controllers/auth_controller_test.exs
  69. 4
    9
      test/integration/controllers/category_controller_test.exs
  70. 16
    6
      test/integration/controllers/entry_controller_test.exs
  71. 186
    83
      test/support/factory.ex
  72. 4
    3
      test/test_helper.exs
  73. 74
    7
      test/unit/http_test.exs
  74. 56
    5
      test/unit/indieweb/app/h_x_app_test.exs
  75. 63
    0
      test/unit/indieweb/app/link_test.exs
  76. 0
    19
      test/unit/indieweb/app/mf2_test.exs
  77. 1
    2
      test/unit/indieweb/auth/code_test.exs
  78. 22
    3
      test/unit/indieweb/auth/scope_test.exs
  79. 0
    84
      test/unit/indieweb/jf2_test.exs
  80. 49
    45
      test/unit/indieweb/mf2/remote_test.exs
  81. 13
    0
      test/unit/indieweb/mf2_test.exs
  82. 12
    4
      test/unit/indieweb/micropub/content_test.exs
  83. 586
    13
      test/unit/indieweb/micropub/entry_test.exs
  84. 27
    0
      test/unit/indieweb/micropub_test.exs
  85. 252
    52
      test/unit/indieweb/post_test.exs
  86. 61
    6
      test/unit/indieweb/relme_test.exs
  87. 21
    35
      test/unit/indieweb/webmention_test.exs
  88. 0
    59
      test/unit/jackywtf_test.exs
  89. 12
    0
      test/unit/koype_test.exs
  90. 45
    0
      test/unit/page_test.exs
  91. 5
    0
      test/unit/profile_test.exs
  92. 1
    0
      test/unit/repo/category_test.exs
  93. 13
    0
      test/unit/repo/entry_json_test.exs
  94. 4
    4
      test/unit/repo/entry_test.exs
  95. 20
    2
      test/unit/repo/setting_test.exs
  96. 68
    6
      test/unit/repo/webmention_test.exs
  97. 40
    0
      test/unit/storage/image_test.exs
  98. 81
    0
      test/unit/storage/json_test.exs
  99. 0
    45
      test/unit/storage/object_data_test.exs
  100. 0
    0
      test/unit/storage/video_test.exs

+ 138
- 0
.credo.exs View File

@@ -0,0 +1,138 @@
1
+# This file contains the configuration for Credo and you are probably reading
2
+# this after creating it with `mix credo.gen.config`.
3
+#
4
+# If you find anything wrong or unclear in this file, please report an
5
+# issue on GitHub: https://github.com/rrrene/credo/issues
6
+#
7
+%{
8
+  #
9
+  # You can have as many configs as you like in the `configs:` field.
10
+  configs: [
11
+    %{
12
+      #
13
+      # Run any config using `mix credo -C <name>`. If no config name is given
14
+      # "default" is used.
15
+      name: "default",
16
+      #
17
+      # These are the files included in the analysis:
18
+      files: %{
19
+        #
20
+        # You can give explicit globs or simply directories.
21
+        # In the latter case `**/*.{ex,exs}` will be used.
22
+        included: ["lib/", "web/", "test/"],
23
+        excluded: [~r"/_build/", ~r"/deps/"]
24
+      },
25
+      #
26
+      # If you create your own checks, you must specify the source files for
27
+      # them here, so they can be loaded by Credo before running the analysis.
28
+      requires: [],
29
+      #
30
+      # Credo automatically checks for updates, like e.g. Hex does.
31
+      # You can disable this behaviour below:
32
+      check_for_updates: true,
33
+      #
34
+      # If you want to enforce a style guide and need a more traditional linting
35
+      # experience, you can change `strict` to `true` below:
36
+      strict: true,
37
+      #
38
+      # If you want to use uncolored output by default, you can change `color`
39
+      # to `false` below:
40
+      color: true,
41
+      #
42
+      # You can customize the parameters of any check by adding a second element
43
+      # to the tuple.
44
+      #
45
+      # To disable a check put `false` as second element:
46
+      #
47
+      #     {Credo.Check.Design.DuplicatedCode, false}
48
+      #
49
+      checks: [
50
+        {Credo.Check.Consistency.ExceptionNames},
51
+        {Credo.Check.Consistency.LineEndings},
52
+        {Credo.Check.Consistency.MultiAliasImportRequireUse},
53
+        {Credo.Check.Consistency.ParameterPatternMatching},
54
+        {Credo.Check.Consistency.SpaceAroundOperators},
55
+        {Credo.Check.Consistency.SpaceInParentheses},
56
+        {Credo.Check.Consistency.TabsOrSpaces},
57
+
58
+        # For some checks, like AliasUsage, you can only customize the priority
59
+        # Priority values are: `low, normal, high, higher`
60
+        {Credo.Check.Design.AliasUsage, priority: :low},
61
+
62
+        # For others you can set parameters
63
+
64
+        # If you don't want the `setup` and `test` macro calls in ExUnit tests
65
+        # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just
66
+        # set the `excluded_macros` parameter to `[:schema, :setup, :test]`.
67
+        {Credo.Check.Design.DuplicatedCode, excluded_macros: []},
68
+
69
+        # You can also customize the exit_status of each check.
70
+        # If you don't want TODO comments to cause `mix credo` to fail, just
71
+        # set this value to 0 (zero).
72
+        {Credo.Check.Design.TagTODO, exit_status: 2},
73
+        {Credo.Check.Design.TagFIXME},
74
+
75
+        {Credo.Check.Readability.FunctionNames},
76
+        {Credo.Check.Readability.LargeNumbers},
77
+        {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 100},
78
+        {Credo.Check.Readability.ModuleAttributeNames},
79
+        {Credo.Check.Readability.ModuleDoc, false},
80
+        {Credo.Check.Readability.ModuleNames},
81
+        {Credo.Check.Readability.ParenthesesOnZeroArityDefs},
82
+        {Credo.Check.Readability.ParenthesesInCondition},
83
+        {Credo.Check.Readability.PredicateFunctionNames},
84
+        {Credo.Check.Readability.PreferImplicitTry},
85
+        {Credo.Check.Readability.RedundantBlankLines},
86
+        {Credo.Check.Readability.StringSigils},
87
+        {Credo.Check.Readability.TrailingBlankLine},
88
+        {Credo.Check.Readability.TrailingWhiteSpace},
89
+        {Credo.Check.Readability.VariableNames},
90
+        {Credo.Check.Readability.Semicolons},
91
+        {Credo.Check.Readability.SpaceAfterCommas},
92
+
93
+        {Credo.Check.Refactor.DoubleBooleanNegation},
94
+        {Credo.Check.Refactor.CondStatements},
95
+        {Credo.Check.Refactor.CyclomaticComplexity},
96
+        {Credo.Check.Refactor.FunctionArity},
97
+        {Credo.Check.Refactor.MatchInCondition},
98
+        {Credo.Check.Refactor.NegatedConditionsInUnless},
99
+        {Credo.Check.Refactor.NegatedConditionsWithElse},
100
+        {Credo.Check.Refactor.Nesting},
101
+        {Credo.Check.Refactor.PipeChainStart},
102
+        {Credo.Check.Refactor.UnlessWithElse},
103
+
104
+        {Credo.Check.Warning.BoolOperationOnSameValues},
105
+        {Credo.Check.Warning.IExPry},
106
+        {Credo.Check.Warning.IoInspect},
107
+        {Credo.Check.Warning.LazyLogging},
108
+        {Credo.Check.Warning.OperationOnSameValues},
109
+        {Credo.Check.Warning.OperationWithConstantResult},
110
+        {Credo.Check.Warning.UnusedEnumOperation},
111
+        {Credo.Check.Warning.UnusedFileOperation},
112
+        {Credo.Check.Warning.UnusedKeywordOperation},
113
+        {Credo.Check.Warning.UnusedListOperation},
114
+        {Credo.Check.Warning.UnusedPathOperation},
115
+        {Credo.Check.Warning.UnusedRegexOperation},
116
+        {Credo.Check.Warning.UnusedStringOperation},
117
+        {Credo.Check.Warning.UnusedTupleOperation},
118
+
119
+        # Controversial and experimental checks (opt-in, just remove `, false`)
120
+        #
121
+        {Credo.Check.Refactor.ABCSize, false},
122
+        {Credo.Check.Refactor.AppendSingleItem, true},
123
+        {Credo.Check.Refactor.VariableRebinding, true},
124
+        {Credo.Check.Warning.MapGetUnsafePass, false},
125
+
126
+        # Deprecated checks (these will be deleted after a grace period)
127
+        {Credo.Check.Readability.Specs, false},
128
+        {Credo.Check.Warning.NameRedeclarationByAssignment, false},
129
+        {Credo.Check.Warning.NameRedeclarationByCase, false},
130
+        {Credo.Check.Warning.NameRedeclarationByDef, false},
131
+        {Credo.Check.Warning.NameRedeclarationByFn, false},
132
+
133
+        # Custom checks can be created using `mix credo.gen.check`.
134
+        #
135
+      ]
136
+    }
137
+  ]
138
+}

+ 24
- 0
.drone.yml View File

@@ -10,6 +10,30 @@ services:
10 10
     image: "redis:5-alpine"
11 11
 
12 12
 steps:
13
+  - name: webdriver
14
+    image: selenium/hub:3.141.59-copernicium
15
+    detach: true
16
+    ports:
17
+      - "4444:4444"
18
+
19
+  - name:chrome
20
+    image: selenium/node-chrome:3.141.59-copernicium
21
+    detach: true
22
+    depends_on:
23
+      - webdriver
24
+    environment:
25
+      HUB_HOST: webdriver
26
+      HUB_PORT: 4444
27
+
28
+  - name: firefox:
29
+    image: selenium/node-firefox:3.141.59-copernicium
30
+    depends_on:
31
+      - webdriver
32
+    environment:
33
+      HUB_HOST: webdriver
34
+      HUB_PORT: 4444
35
+    detach: true
36
+
13 37
   - name: objectstorage
14 38
     image: "minio/minio:RELEASE.2018-11-22T02-51-56Z"
15 39
     environment:

+ 3
- 2
.editorconfig View File

@@ -6,10 +6,11 @@ indent_style = space
6 6
 indent_size = 2
7 7
 end_of_line = lf
8 8
 insert_final_newline = true
9
+line_length = 80
9 10
 
10 11
 [*.markdown,*.md]
11 12
 line_length = 80
12 13
 
13 14
 [*.ex,*.exs]
14
-line_length = 80
15
-max_line_length = 100
15
+line_length = 100
16
+max_line_length = 120

+ 2
- 2
.formatter.exs View File

@@ -1,4 +1,4 @@
1 1
 [
2
-  inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
3
-  line_length: 100,
2
+  inputs: ["mix.exs", "{web,config,lib,test}/**/*.{ex,exs}"],
3
+  line_length: 120,
4 4
 ]

+ 1
- 1
.lvimrc View File

@@ -5,7 +5,7 @@
5 5
 
6 6
 DirenvExport
7 7
 
8
-let s:command_prefix = "docker-compose run -e OBJECT_STORAGE_ASSET_HOST='http://localhost:4001/koype-test' -e MIX_ENV=test --no-deps --rm site"
8
+let s:command_prefix = 'docker-compose run -e OBJECT_STORAGE_ASSET_HOST="http://localhost:4001/koype-test" -e MIX_ENV=test --no-deps --rm site'
9 9
 
10 10
 function! DockerComposeTransform(cmd) abort
11 11
   return s:command_prefix . ' ' . a:cmd

+ 1
- 0
.node-version View File

@@ -0,0 +1 @@
1
+8.14.0

+ 27
- 3
.projections.json View File

@@ -1,10 +1,34 @@
1 1
 {
2
-  "lib/web/controllers/*.ex": {
2
+  "lib/web/controllers/**/*.ex": {
3 3
     "alternative": "test/integration/controllers/{}_test.exs",
4
-    "type": "test"
4
+    "type": "source"
5 5
   },
6
-  "lib/web/views/*.ex": {
6
+  "lib/web/views/**/*.ex": {
7 7
     "alternative": "test/integration/views/{}_test.exs",
8
+    "type": "source"
9
+  },
10
+  "lib/**/*.ex": {
11
+    "alternative": "test/unit/{}.ex",
12
+    "type": "source"
13
+  },
14
+  "lib/repo/*.ex": {
15
+    "alternative": "test/unit/repo/{}.ex",
16
+    "type": "source"
17
+  },
18
+  "test/integration/controllers/**/*_test.exs": {
19
+    "alternative": "web/controllers/{}.ex",
8 20
     "type": "test"
21
+  },
22
+  "test/integration/views/**/*_test.exs": {
23
+    "alternative": "web/views/{}.ex",
24
+    "type": "test"
25
+  },
26
+  "test/unit/**/*_test.exs": {
27
+    "alternative": "lib/{}.ex",
28
+    "type": "test"
29
+  },
30
+  "*": {
31
+    "make": "mix",
32
+    "start": "mix phx.server"
9 33
   }
10 34
 }

+ 1
- 1
Dockerfile View File

@@ -18,9 +18,9 @@ RUN sh /tmp/koype-docker/prepare.sh
18 18
 WORKDIR /opt/koype
19 19
 
20 20
 COPY . /opt/koype/
21
+VOLUME /opt/koype/priv/repo/db
21 22
 RUN sh /tmp/koype-docker/build.sh
22 23
 
23
-VOLUME /opt/koype/priv/repo/db
24 24
 RUN sh /tmp/koype-docker/cleanup.sh
25 25
 
26 26
 SHELL ["/bin/bash"]

+ 13
- 0
INSTALL.markdown View File

@@ -7,17 +7,30 @@ own their identity online. Or something like that, I'm still working on it.
7 7
 
8 8
   * [Redis][] 5.0.0
9 9
   * [Elixir][] 1.6.6
10
+  * [SQLite][] 3.24.0
10 11
 
11 12
 ## Development
12 13
 
13 14
 ### Using Docker Compose
14 15
 
16
+Before getting into [Docker Compose][1], you'll need to create the environment
17
+variable file to prime your system for use. You can do this by running `cp .env.example .env`
18
+
15 19
 Being that Koype's tailored to run currently under a Docker environment, the 
16 20
 ideal tool for local development is [Docker Compose][1]. Check out its
17 21
 documentation for instructions on how to install that. Once you've got installed,
18 22
 run `docker-compose up` in the directory.
19 23
 
24
+Looking for a one-liner / copy-paste friendly?
25
+
26
+```sh
27
+cp .env.example .env
28
+pip install docker-compose
29
+docker-compose up
30
+```
31
+
20 32
 [1]: https://docs.docker.com/compose/install/
21 33
 [2]: http://phoenixframework.org/
22 34
 [redis]: https://redis.io/
23 35
 [elixir]: https://elixir-lang.org/
36
+[sqlite]: https://sqlite.org/

+ 8
- 4
README.markdown View File

@@ -1,16 +1,20 @@
1
-# new.jacky.wtf
1
+# Koype
2 2
 
3 3
 [![Build Status](https://ci.jacky.wtf/api/badges/indieweb/koype/status.svg)](https://ci.jacky.wtf/indieweb/koype)
4 4
 
5
-This represents the foundation of my next-generation website. It's written
6
-from the ground-up by hand with a plug-in design but purely for my use.
7
-I plan to run this on my host machine using Dokku.
5
+Koype is a server-side Web app that provides people with the ability to [own their data][4] and still interact with [other forms of social media][5]. The notion with
6
+Koype is that everything you post is _your data_ and you are your own source of truth.
8 7
 
9 8
 Setup information is in [`INSTALL`][2].
10 9
 
10
+General documentation can be found in [`docs/`][3].
11
+
11 12
 ### Licensing
12 13
 
13 14
 This project's source code is licensed under the [AGPL3][1].
14 15
 
15 16
 [1]: https://choosealicense.com/licenses/agpl-3.0/
16 17
 [2]: ./INSTALL.markdown
18
+[3]: ./docs
19
+[4]: https://indieweb.org/ownyourdata
20
+[5]: https://indieweb.org/silo

+ 1
- 1
docker/scripts/build.sh View File

@@ -5,7 +5,7 @@ HEX_HTTP_TIMEOUT=600
5 5
 NODE_ENV=${ENV}
6 6
 
7 7
 echo " ---> [npm] Pulling dependencies..."
8
-npm install --no-bin-links || exit 50
8
+npm install --verbose --no-bin-links || exit 50
9 9
 
10 10
 echo " ---> [mix] Preparing..."
11 11
 mix local.hex --force || exit 10

+ 0
- 3
docker/scripts/post-deploy.sh View File

@@ -5,6 +5,3 @@ touch "/opt/koype/priv/repo/db/${MIX_ENV:-prod}.db" || exit 15
5 5
 
6 6
 echo " ----> [deploy:post] Setting up database..."
7 7
 mix ecto.setup || exit 20
8
-
9
-echo " ----> [deploy:post] Update static digests..."
10
-mix phx.digest || exit 40

+ 3
- 0
docker/scripts/pre-deploy.sh View File

@@ -14,3 +14,6 @@ echo " ----> [deploy:pre] <skip> Confirm permissions on object storage..."
14 14
 
15 15
 echo " ----> [deploy:pre] <skip> Run production-ready checks... "
16 16
 # mix koype.smoke_test || exit 60
17
+
18
+echo " ----> [deploy:pre] Update static digests..."
19
+mix phx.digest || exit 50

+ 7
- 1
docker/scripts/prepare.sh View File

@@ -5,6 +5,7 @@ ONE_WEEK_IN_MINUTES=10080
5 5
 echo " ---> [apk] Syncing repos (allowed to fail)..."
6 6
 apk update --cache-max-age="${ONE_WEEK_IN_MINUTES}" --verbose
7 7
 
8
+
8 9
 echo " ---> [apk] Fetching baseline packages..."
9 10
 apk add --verbose \
10 11
   bash \
@@ -12,13 +13,18 @@ apk add --verbose \
12 13
   coreutils \
13 14
   tzdata \
14 15
   curl \
15
-  sqlite-libs \
16 16
   inotify-tools \
17 17
   imagemagick \
18 18
   nodejs=8.14.0-r0 \
19 19
   npm=8.14.0-r0 \
20 20
   || exit 20
21 21
 
22
+apk add --verbose \
23
+  --repository="http://dl-cdn.alpinelinux.org/alpine/edge/main" \
24
+  sqlite-libs \
25
+  || exit 21
26
+
27
+
22 28
 echo " ---> [apk] Fetching development packages..."
23 29
 apk add --virtual=build --verbose \
24 30
   gcc \

+ 1
- 3
lib/application.ex View File

@@ -8,9 +8,9 @@ defmodule Koype.Application do
8 8
     load_runtime_config()
9 9
 
10 10
     children = [
11
+      supervisor(Koype.Repo, []),
11 12
       Koype.Cache.get_supervisor(),
12 13
       worker(Guardian.DB.Token.SweeperServer, []),
13
-      supervisor(Koype.Repo, []),
14 14
       supervisor(Koype.Web.Endpoint, [])
15 15
     ]
16 16
 
@@ -25,10 +25,8 @@ defmodule Koype.Application do
25 25
     [
26 26
       :koype,
27 27
       :logger,
28
-      :sentry,
29 28
       :phoenix,
30 29
       :cowboy,
31
-      :sse,
32 30
       :arc,
33 31
       :ex_aws
34 32
     ]

+ 1
- 1
lib/cache.ex View File

@@ -73,7 +73,7 @@ defmodule Koype.Cache do
73 73
         {:ok, value}
74 74
 
75 75
       {:error, error} ->
76
-        Logger.warn("Failed to save #{key} to cache: #{error}.")
76
+        Logger.warn("Failed to save #{key} to cache: #{inspect(error)}.")
77 77
         {:error, error}
78 78
     end
79 79
   end

+ 18
- 0
lib/format.ex View File

@@ -0,0 +1,18 @@
1
+defmodule Koype.Format do
2
+  @moduledoc """
3
+  Helper module to convert a struct into a social format.
4
+
5
+  Provides a set of wrapper methods to the underlying structures
6
+  used for the social media format in question.
7
+
8
+  Targeting:
9
+    - Microformats2
10
+    - JsonFeed
11
+    - ActivityStreams 2
12
+  """
13
+
14
+  def to(format, data)
15
+  # def to(:mf2_json, data), do: Koype.Format.MF2.generate(data)
16
+  # def to(:jf2, data), do: Koype.Format.JF2.generate(data)
17
+  # def to(:jsonfeed, data), do: Koype.Format.JF2.generate(data)
18
+end

+ 33
- 23
lib/http.ex View File

@@ -13,40 +13,47 @@ defmodule Koype.Http do
13 13
   @timeout 30_000
14 14
 
15 15
   defmodule Response do
16
+    @moduledoc "Represents a HTTP response."
16 17
     @enforce_keys [:code]
17 18
     defstruct ~w(code body headers raw)a
18 19
     @type t :: %Response{code: Integer.t(), body: Map.t(), headers: Map.t(), raw: any()}
19 20
   end
20 21
 
21 22
   defmodule Error do
23
+    @moduledoc "Represents a HTTP error."
22 24
     @enforce_keys [:reason]
23 25
     defstruct ~w(reason raw)a
24 26
     @type t :: %Error{reason: any(), raw: any()}
25 27
   end
26 28
 
27 29
   for method <- ~w(get post put patch delete head options)a do
30
+    @doc "Helper method to dispatch a #{method} request."
31
+    @spec unquote(method)(url :: String.t(), args :: Keyword.t()) :: {:ok, Response.t()} | {:error, Error.t()}
28 32
     def unquote(method)(url, args \\ []) do
29 33
       request(unquote(method), url, args)
30 34
     end
31 35
   end
32 36
 
33
-  @spec request(method :: Atom.t(), url :: String.t(), args :: Keyword.t()) ::
34
-          {:ok, Response.t()} | {:error, Error.t()}
37
+  @doc "Dispatches a request over the network."
38
+  @spec request(method :: Atom.t(), url :: String.t(), args :: Keyword.t()) :: {:ok, Response.t()} | {:error, Error.t()}
35 39
   def request(method, url, args \\ []) do
36 40
     [
37
-      &do_request_poison/1,
38
-      &do_request_potion/1
41
+      &do_request_potion/1,
42
+      &do_request_poison/1
39 43
     ]
40
-    |> Enum.reduce_while({:error, :unspecified_network_error}, fn handler, _acc ->
41
-      Logger.info("Sending request #{url} via method #{method}.")
44
+    |> Enum.reduce_while({:error, :unspecified_network_error}, fn handler, acc ->
45
+      Logger.info("Sending request to #{url} via method #{method}")
42 46
       resp = handler.([url: url, method: method] ++ args)
43 47
 
44 48
       case resp do
45 49
         {:ok, resp} ->
46 50
           {:halt, {:ok, resp}}
47 51
 
48
-        {:error, err} ->
49
-          {:cont, {:error, err}}
52
+        {:error, resp} ->
53
+          {:halt, {:error, resp}}
54
+
55
+        :fatal ->
56
+          {:cont, acc}
50 57
       end
51 58
     end)
52 59
   end
@@ -56,8 +63,6 @@ defmodule Koype.Http do
56 63
 
57 64
     options = [
58 65
       timeout: @timeout,
59
-      body: %{},
60
-      query: %{},
61 66
       follow_redirects: true,
62 67
       auto_sni: true,
63 68
       headers: headers
@@ -71,13 +76,10 @@ defmodule Koype.Http do
71 76
         HTTPotion.request(
72 77
           final_args[:method],
73 78
           final_args[:url],
74
-          body: final_args[:body],
75
-          headers: final_args[:headers],
76
-          query: final_args[:query],
77
-          follow_redirects: final_args[:follow_redirects],
78
-          timeout: final_args[:timeout],
79
-          basic_auth: final_args[:basic_auth],
80
-          auto_sni: final_args[:auto_sni]
79
+          Keyword.take(
80
+            final_args,
81
+            ~w(body headers query follow_redirects timeout basic_auto auto_sni)a
82
+          )
81 83
         )
82 84
 
83 85
       case result do
@@ -85,12 +87,16 @@ defmodule Koype.Http do
85 87
           {:ok, %Koype.Http.Response{code: code, body: body, headers: resp.headers, raw: resp}}
86 88
 
87 89
         %HTTPotion.ErrorResponse{message: reason} = resp ->
88
-          {:error, %Koype.Http.Error{reason: reason, raw: resp}}
90
+          {:error, %Koype.Http.Error{reason: reason}}
89 91
       end
92
+    rescue
93
+      err ->
94
+        Logger.error("Failed to handle #{final_args[:url]} with HTTPotion: #{inspect(err)}")
95
+        :fatal
90 96
     catch
91 97
       err ->
92
-        Logger.info("Failed to handle #{final_args[:url]} with HTTPoison: #{IO.inspect(err)}")
93
-        {:error, err}
98
+        Logger.error("Failed to handle #{final_args[:url]} with HTTPotion: #{err}")
99
+        :fatal
94 100
     end
95 101
   end
96 102
 
@@ -115,12 +121,16 @@ defmodule Koype.Http do
115 121
           {:ok, %Koype.Http.Response{code: code, body: body, headers: resp.headers, raw: resp}}
116 122
 
117 123
         {:error, %HTTPoison.Error{reason: reason} = resp} ->
118
-          {:error, %Koype.Http.Error{reason: reason, raw: resp}}
124
+          {:error, %Koype.Http.Error{reason: reason}}
119 125
       end
126
+    rescue
127
+      err ->
128
+        Logger.error("Failed to handle #{final_args[:url]} with HTTPoison: #{inspect(err)}")
129
+        :fatal
120 130
     catch
121 131
       err ->
122
-        Logger.info("Failed to handle #{final_args[:url]} with HTTPoison: #{IO.inspect(err)}")
123
-        {:error, err}
132
+        Logger.error("Failed to handle #{final_args[:url]} with HTTPoison: #{err}")
133
+        :fatal
124 134
     end
125 135
   end
126 136
 end

+ 4
- 5
lib/indieweb/app.ex View File

@@ -24,15 +24,14 @@ defmodule IndieWeb.App do
24 24
       [
25 25
         ## Recommended parsers to stop at
26 26
         IndieWeb.App.HxApp,
27
-        IndieWeb.App.MF2,
28 27
 
29 28
         ## Open stopgap solutions
30
-        IndieWeb.App.WebManifest,
31
-        IndieWeb.App.Link,
29
+        IndieWeb.App.Link
30
+        # IndieWeb.App.WebManifest,
32 31
 
33 32
         ## Corporate-backed standards
34
-        IndieWeb.App.OpenGraph,
35
-        IndieWeb.App.SchemaOrg
33
+        # IndieWeb.App.OpenGraph,
34
+        # IndieWeb.App.SchemaOrg
36 35
 
37 36
         ## Experiments
38 37
         # IndieWeb.App.IndieStore

+ 12
- 15
lib/indieweb/app/h_x_app.ex View File

@@ -1,14 +1,19 @@
1 1
 defmodule IndieWeb.App.HxApp do
2 2
   @moduledoc """
3
-  Provides baseline information about an application from the
4
-  Microformats2 information it provides.
3
+  Parses h-x-app information from a Website.
4
+
5
+  Using the specificiation of h-x-app information outlined at 
6
+  https://indieweb.org/h-x-app. This allows platforms that are
7
+  richly formatted with said markup to be presented to Koype
8
+  in a useful manner.
9
+
10
+  NOTE: Cache this information to database for usage tracking.
5 11
   """
6 12
   @behaviour IndieWeb.App.Parser
7 13
 
8 14
   alias IndieWeb.MF2
9 15
   require Logger
10 16
 
11
-  # TODO: Remove x-app usage here.
12 17
   defp do_format(data) do
13 18
     result =
14 19
       Enum.reduce_while(
@@ -45,19 +50,11 @@ defmodule IndieWeb.App.HxApp do
45 50
   defp do_fetch(url) do
46 51
     case MF2.Remote.fetch(url) do
47 52
       {:error, error} ->
48
-        Logger.warn("Failed to resolve MF2 data for #{url}: #{error}")
49
-        false
50
-
51
-      {:ok, %{items: nil}} ->
52
-        Logger.info("Can't find any items for #{url}.")
53
-        false
54
-
55
-      {:ok, %{items: []}} ->
56
-        Logger.info("No items for #{url} were available.")
53
+        Logger.warn("Failed to resolve MF2 data for #{url} to use for h-x-app: #{error}")
57 54
         false
58 55
 
59 56
       {:ok, mf2_data} ->
60
-        Logger.info("Obtained MF2 data for #{url}")
57
+        Logger.info("Obtained MF2 data for #{url} to use for h-x-app.")
61 58
         {:ok, mf2_data}
62 59
     end
63 60
   end
@@ -70,6 +67,6 @@ defmodule IndieWeb.App.HxApp do
70 67
     end
71 68
   end
72 69
 
73
-  @impl false
74
-  def clear(_uri), do: :ok
70
+  @impl true
71
+  def clear(uri), do: IndieWeb.MF2.Remote.flush(uri)
75 72
 end

+ 20
- 31
lib/indieweb/app/link.ex View File

@@ -1,5 +1,14 @@
1 1
 defmodule IndieWeb.App.Link do
2 2
   @behaviour IndieWeb.App.Parser
3
+  @moduledoc """
4
+  Extracts information about app from <link> info.
5
+
6
+  This takes information stored in the <link rel> bits of the site and uses
7
+  it to render generic information about the application. It's not the best
8
+  solution.
9
+
10
+  FIXME: Decorate with <meta> information.
11
+  """
3 12
 
4 13
   alias IndieWeb.MF2
5 14
   require Logger
@@ -7,46 +16,26 @@ defmodule IndieWeb.App.Link do
7 16
   defp do_fetch(url) do
8 17
     case MF2.Remote.fetch(url) do
9 18
       {:error, error} ->
10
-        Logger.warn("Failed to resolve <link> data for #{url}: #{error}")
11
-        false
12
-
13
-      {:ok, %{rels: nil}} ->
14
-        Logger.info("Can't find any <link rel> info for #{url}.")
15
-        false
16
-
17
-      {:ok, %{rels: []}} ->
18
-        Logger.info("No <link rel> for #{url} were available.")
19
+        Logger.warn("Failed to resolve <link rel=> data for #{url}: #{error}")
19 20
         false
20 21
 
21
-      {:ok, mf2_data} ->
22
-        Logger.info("Obtained <link rel> data for #{url}.")
23
-        {:ok, mf2_data[:rels] |> Map.put(:url, url)}
22
+      {:ok, %{"rels" => rels}} when is_map(rels) ->
23
+        Logger.info("Obtained <link rel=> data for #{url}.")
24
+        {:ok, Map.put(rels, "url", url)}
24 25
     end
25 26
   end
26 27
 
27
-  defp do_format(%{icon: icon, url: url} = rels) when is_list(icon) do
28
-    icon = rels[:icon] |> List.first()
29
-    uri = URI.parse(url)
30
-
28
+  defp do_format(%{"icon" => icon, "url" => url} = rels) when is_list(icon) do
31 29
     app_data = %{
32
-      logo: icon,
33
-      url: uri |> URI.to_string(),
34
-      name: uri.host
30
+      logo: List.first(icon),
31
+      name: URI.parse(url).host,
32
+      url: url
35 33
     }
36 34
 
37 35
     {:ok, app_data}
38 36
   end
39 37
 
40
-  defp do_format(%{icon: nil} = _rels) do
41
-    {:error, :icon_missing_for_link}
42
-  end
43
-
44
-  defp do_format(data) do
45
-    cond do
46
-      !Map.has_key?(data, :icon) -> {:error, :no_icon_for_data}
47
-      true -> {:error, :unknown_error}
48
-    end
49
-  end
38
+  defp do_format(_), do: {:error, :no_useful_link_data}
50 39
 
51 40
   @impl true
52 41
   def resolve(uri) do
@@ -56,6 +45,6 @@ defmodule IndieWeb.App.Link do
56 45
     end
57 46
   end
58 47
 
59
-  @impl false
60
-  def clear(_), do: :ok
48
+  @impl true
49
+  def clear(uri), do: IndieWeb.MF2.Remote.flush(uri)
61 50
 end

+ 0
- 60
lib/indieweb/app/mf2.ex View File

@@ -1,60 +0,0 @@
1
-defmodule IndieWeb.App.MF2 do
2
-  @behaviour IndieWeb.App.Parser
3
-  @moduledoc """
4
-  Provides baseline information about an application from the
5
-  Microformats2 information it provides.
6
-  """
7
-  alias IndieWeb.MF2
8
-  require Logger
9
-
10
-  # TODO: Remove x-app usage here.
11
-  defp do_format(data) do
12
-    case MF2.get_format(data, :"x-app") do
13
-      {:ok, mf2_data} ->
14
-        app_data =
15
-          ~w(name logo url)a
16
-          |> Enum.map(fn key ->
17
-            {key, mf2_data |> MF2.get_value!(key) |> List.first()}
18
-          end)
19
-          |> Map.new()
20
-
21
-        {:ok, app_data}
22
-
23
-      {:error, _error} ->
24
-        {:error, :no_app_info}
25
-    end
26
-  end
27
-
28
-  defp do_fetch(url) do
29
-    case MF2.Remote.fetch(url) do
30
-      {:error, error} ->
31
-        Logger.warn("Failed to resolve MF2 data for #{url}: #{error}")
32
-        false
33
-
34
-      {:ok, %{items: nil}} ->
35
-        Logger.info("Can't find any items for #{url}.")
36
-        false
37
-
38
-      {:ok, %{items: []}} ->
39
-        Logger.info("No items for #{url} were available.")
40
-        false
41
-
42
-      {:ok, mf2_data} ->
43
-        Logger.info("Obtained MF2 data for #{url}")
44
-        {:ok, mf2_data}
45
-    end
46
-  end
47
-
48
-  @behaviour IndieWeb.App.Parser
49
-
50
-  @impl true
51
-  def resolve(uri) do
52
-    case do_fetch(uri) do
53
-      {:ok, mf2_data} -> do_format(mf2_data)
54
-      false -> {:error, :failed_to_fetch_mf2_data}
55
-    end
56
-  end
57
-
58
-  @impl false
59
-  def clear(_uri), do: :ok
60
-end

+ 3
- 4
lib/indieweb/app/opengraph.ex View File

@@ -1,11 +1,10 @@
1 1
 defmodule IndieWeb.App.OpenGraph do
2
+  @moduledoc false
2 3
   @behaviour IndieWeb.App.Parser
3 4
 
4 5
   @impl true
5
-  def resolve(_url) do
6
-    {:error, :not_implemented}
7
-  end
6
+  def resolve(_url), do: {:error, :not_implemented}
8 7
 
9
-  @impl false
8
+  @impl true
10 9
   def clear(_uri), do: :ok
11 10
 end

+ 3
- 2
lib/indieweb/app/schemaorg.ex View File

@@ -1,9 +1,10 @@
1 1
 defmodule IndieWeb.App.SchemaOrg do
2
+  @moduledoc false
2 3
   @behaviour IndieWeb.App.Parser
3 4
 
4 5
   @impl true
5
-  def resolve(_), do: :ok
6
+  def resolve(_), do: {:error, :not_implemented}
6 7
 
7
-  @impl false
8
+  @impl true
8 9
   def clear(_), do: :ok
9 10
 end

+ 3
- 2
lib/indieweb/app/webmanifest.ex View File

@@ -1,9 +1,10 @@
1 1
 defmodule IndieWeb.App.WebManifest do
2
+  @moduledoc false
2 3
   @behaviour IndieWeb.App.Parser
3 4
 
4 5
   @impl true
5
-  def resolve(url), do: :ok
6
+  def resolve(_), do: {:error, :not_implemented}
6 7
 
7
-  @impl false
8
+  @impl true
8 9
   def clear(_), do: :ok
9 10
 end

+ 1
- 1
lib/indieweb/auth/scope.ex View File

@@ -116,5 +116,5 @@ defmodule IndieWeb.Auth.Scope do
116 116
     })
117 117
   end
118 118
 
119
-  def can_upload?(scope), do: Enum.member?(scope, "media")
119
+  def can_upload?(scope) when is_list(scope), do: Enum.member?(scope, "media")
120 120
 end

+ 0
- 130
lib/indieweb/jf2.ex View File

@@ -1,130 +0,0 @@
1
-defmodule IndieWeb.JF2 do
2
-  @moduledoc false
3
-  alias Koype.Repo.Entry
4
-  alias Koype.Storage.ObjectData
5
-  alias IndieWeb.Post
6
-
7
-  # FIXME: Handle the case of a singular value
8
-  defp do_extract_categories(jf2, %{category: categories}) when is_list(categories) do
9
-    Map.put(
10
-      jf2,
11
-      :category,
12
-      Enum.map(categories, fn c ->
13
-        {:ok, obj} = Jason.decode(c)
14
-        obj["name"]
15
-      end)
16
-    )
17
-  end
18
-
19
-  defp do_extract_categories(jf2, _), do: jf2
20
-
21
-  defp do_extract_content(jf2, object_data) do
22
-    content = object_data[:content]
23
-
24
-    cond do
25
-      is_map(content) || is_binary(content) ->
26
-        Map.put(jf2, :content, content)
27
-
28
-      is_list(content) && length(content) == 1 ->
29
-        Map.put(jf2, :content, List.first(content))
30
-
31
-      is_binary(content) ->
32
-        Map.put(jf2, :content, content)
33
-
34
-      true ->
35
-        jf2
36
-    end
37
-  end
38
-
39
-  defp do_extract_post_type_properties(jf2, object_data) do
40
-    with(
41
-      {:ok, types} <- Post.determine_type(object_data),
42
-      type <- Post.determine_dominant_type(types, object_data)
43
-    ) do
44
-      jf2
45
-      |> (fn jf2 -> Map.put(jf2, :name, Post.determine_title(type, object_data)) end).()
46
-      |> (fn jf2 ->
47
-            case type do
48
-              :like ->
49
-                Map.put(
50
-                  jf2,
51
-                  :"like-of",
52
-                  Post.get_properties_for_type(type, object_data)
53
-                )
54
-
55
-              :reply ->
56
-                Map.put(
57
-                  jf2,
58
-                  :"in-reply-to",
59
-                  Post.get_properties_for_type(type, object_data)
60
-                )
61
-
62
-              :bookmark ->
63
-                Map.put(
64
-                  jf2,
65
-                  :"bookmark-of",
66
-                  Post.get_properties_for_type(type, object_data)
67
-                )
68
-
69
-              :repost ->
70
-                Map.put(
71
-                  jf2,
72
-                  :"repost-of",
73
-                  Post.get_properties_for_type(type, object_data)
74
-                )
75
-
76
-              :photo ->
77
-                photo = Post.get_properties_for_type(type, object_data)
78
-
79
-                handle_photo = fn photo ->
80
-                  Map.get(photo, :sizes) |> Enum.map(fn {_, photo} -> %{url: photo[:uri]} end)
81
-                end
82
-
83
-                Map.put(
84
-                  jf2,
85
-                  :photo,
86
-                  cond do
87
-                    is_list(photo) -> Enum.map(photo, fn p -> handle_photo.(p) end)
88
-                    is_map(photo) -> handle_photo.(photo)
89
-                    true -> true
90
-                  end
91
-                )
92
-
93
-              :event ->
94
-                Map.merge(
95
-                  jf2,
96
-                  Map.take(object_data, ~w(location start end duration)a)
97
-                )
98
-
99
-              :article ->
100
-                Map.merge(
101
-                  jf2,
102
-                  Map.take(object_data, ~w(name summary)a)
103
-                )
104
-
105
-              _ ->
106
-                jf2
107
-            end
108
-          end).()
109
-    end
110
-  end
111
-
112
-  @doc false
113
-  def from_entry(%Entry{} = entry) do
114
-    {:ok, object_data} = ObjectData.fetch(entry)
115
-    props = object_data[:properties]
116
-
117
-    base_attrs = %{
118
-      "@context": "http://www.w3.org/ns/jf2",
119
-      type: entry.type,
120
-      url: Entry.get_uri(entry),
121
-      published: props[:published]
122
-    }
123
-
124
-    base_attrs
125
-    |> do_extract_post_type_properties(props)
126
-    |> do_extract_content(props)
127
-    |> do_extract_categories(props)
128
-    |> ObjectData.flatten_values()
129
-  end
130
-end

+ 1
- 7
lib/indieweb/mf2.ex View File

@@ -18,13 +18,7 @@ defmodule IndieWeb.MF2 do
18 18
 
19 19
   def get_value!(_mf2, _property), do: {:error, :no_properties}
20 20
 
21
-  def parse(data), do: parse(data, Koype.host())
22
-
23
-  def parse(data, base_uri) do
21
+  def parse(data, base_uri \\ Koype.host()) do
24 22
     Microformats2.parse(data, base_uri)
25 23
   end
26
-
27
-  def from_entry(%Koype.Repo.Entry{type: "note"} = entry) do
28
-    Koype.Storage.ObjectData.fetch(entry)
29
-  end
30 24
 end

+ 12
- 14
lib/indieweb/mf2/remote.ex View File

@@ -9,36 +9,34 @@ defmodule IndieWeb.MF2.Remote do
9 9
   alias Koype.Http
10 10
   require Logger
11 11
 
12
-  defp do_normalize(json), do: Jason.decode(json, keys: :atoms)
12
+  defp do_normalize(json), do: Jason.decode(json, keys: :strings, strings: :copy)
13 13
   defp do_serialize(mf2), do: Jason.encode_to_iodata(mf2)
14 14
   defp key_func(uri), do: :crypto.hash(:sha256, uri) |> Base.encode16()
15 15
 
16 16
   defp fetch_mf2(uri) do
17 17
     %{scheme: base_scheme, host: base_host} = URI.parse(uri)
18 18
     base_uri = %URI{scheme: base_scheme, host: base_host}
19
-    Logger.debug("Attempting to fetch #{uri}...")
19
+    Logger.debug(fn -> "Attempting to fetch #{uri}..." end)
20 20
 
21 21
     with(
22
-      {:ok, %Http.Response{code: 200, body: body}} <- Http.get(uri),
23
-      mf2 when is_map(mf2) <- IndieWeb.MF2.parse(body, base_uri),
24
-      true <- Map.has_key?(mf2, :items)
22
+      {:ok, %Http.Response{code: code, body: body}} when (code >= 200 and code < 300) or code == 410 <- Http.get(uri),
23
+      mf2 when is_map(mf2) <- IndieWeb.MF2.parse(body, base_uri)
25 24
     ) do
26
-      Logger.debug("Obtained valid response from #{uri}; serializing...")
25
+      Logger.debug(fn -> "Obtained valid response from #{uri}; serializing..." end)
27 26
       mf2 |> do_serialize
28 27
     else
29
-      {:error, %Http.Error{reason: reason}} ->
30
-        Logger.debug("Failed to fetch #{uri}: #{reason}")
31
-        {:error, reason}
32
-
33 28
       {:error, error} ->
34
-        Logger.debug("Failed to fetch #{uri} for unrecognized error: #{error}")
35
-        {:error, :unexpected_error}
29
+        Logger.error("Failed to fetch #{uri}: #{inspect(error)}")
30
+        {:error, error}
36 31
 
37 32
       :error ->
38 33
         {:error, :mf2_parsing_failure}
39 34
 
40 35
       false ->
41 36
         {:error, :no_mf2_data_found}
37
+
38
+      {:ok, %Http.Response{}} ->
39
+        {:error, :no_mf2_data_found}
42 40
     end
43 41
   end
44 42
 
@@ -46,8 +44,8 @@ defmodule IndieWeb.MF2.Remote do
46 44
     Logger.info("Fetching #{uri}...")
47 45
 
48 46
     case Cache.fetch_for(uri, &key_func/1) do
49
-      {:error, _} ->
50
-        Logger.info("#{uri} not found in cache; refreshing.")
47
+      {:error, error} ->
48
+        Logger.info("#{uri} not found in cache due to error #{error}; refreshing.")
51 49
         refresh(uri)
52 50
 
53 51
       {:ok, nil} ->

+ 7
- 5
lib/indieweb/micropub.ex View File

@@ -1,7 +1,7 @@
1 1
 defmodule IndieWeb.Micropub do
2
-  @actionable_keys ~w(replace add delete undelete)
2
+  @actionable_keys ~w(replace add delete)
3 3
   @reserved_mp_param_keys ~w(h action url content) ++ @actionable_keys
4
-  @sensitive_properties [:access_token, "access_token"]
4
+  @sensitive_properties ~w(access_token)c
5 5
   @micropub_prefix "mp-"
6 6
 
7 7
   def reserved_keyword?(key) do
@@ -13,18 +13,20 @@ defmodule IndieWeb.Micropub do
13 13
   end
14 14
 
15 15
   @doc "Gets the sub-map where the keys are known to be actionable for update operations."
16
-  @spec actionable(params :: Map.t()) :: Map.t()
16
+  @spec actionable(params :: map()) :: map()
17 17
   def actionable(params), do: Map.take(params, @actionable_keys)
18 18
 
19
+  @spec actionable(properties :: map()) :: map()
19 20
   def scrub_properties(properties), do: Map.drop(properties, @sensitive_properties)
20 21
 
22
+  @spec extract_params(properties :: map()) :: {map(), map()}
21 23
   def extract_params(properties) do
22 24
     scrubbed_properties = properties |> scrub_properties
23 25
     reserved_keys = scrubbed_properties |> Map.keys() |> Enum.filter(&reserved_keyword?/1)
24 26
 
25 27
     reserved_params = scrubbed_properties |> Map.take(reserved_keys) |> Map.new()
26
-    entry_params = scrubbed_properties |> Map.drop(reserved_keys) |> Map.new()
28
+    params = scrubbed_properties |> Map.drop(reserved_keys) |> Map.new()
27 29
 
28
-    {entry_params, reserved_params}
30
+    {params, reserved_params}
29 31
   end
30 32
 end

+ 5
- 12
lib/indieweb/micropub/content.ex View File

@@ -1,7 +1,6 @@
1 1
 defmodule IndieWeb.Micropub.Content do
2 2
   @doc "Handles the prescribed action for the content with the provided arguments."
3
-  @callback invoke(action :: String.t(), arguments :: Keyword.t()) ::
4
-              :ok | {:ok, any()} | {:error, any()}
3
+  @callback invoke(action :: String.t(), arguments :: Keyword.t()) :: :ok | {:ok, any()} | {:error, any()}
5 4
 
6 5
   @doc "Determines if the content action is supported."
7 6
   @callback supports?(action :: String.t()) :: true | false
@@ -31,6 +30,8 @@ defmodule IndieWeb.Micropub.Content do
31 30
   @spec process_property(name :: String.t(), value: any()) :: {:error, any()} | {:ok, any()}
32 31
   def process_property(name, value)
33 32
 
33
+  # NOTE: Add support for detecting venue information.
34
+  # NOTE: Add support for piping information to atlas.p3k.io
34 35
   def process_property("location", value) when is_binary(value) do
35 36
     cond do
36 37
       String.starts_with?(value, "geo:") ->
@@ -50,11 +51,6 @@ defmodule IndieWeb.Micropub.Content do
50 51
   def process_property("category", categories) when is_list(categories) do
51 52
     Enum.reduce_while(categories, {:ok, []}, fn category, {:ok, results} ->
52 53
       case process_property("category", category) do
53
-        {:error, :category_exists} ->
54
-          {:ok, record} = Koype.Repo.Category.fetch(category)
55
-          {:ok, encoded_record} = Jason.encode(record)
56
-          {:cont, {:ok, results ++ [encoded_record]}}
57
-
58 54
         {:error, _} = error ->
59 55
           {:halt, error}
60 56
 
@@ -65,15 +61,12 @@ defmodule IndieWeb.Micropub.Content do
65 61
   end
66 62
 
67 63
   def process_property("category", category) when is_binary(category) do
68
-    case Koype.Repo.Category.create(%{name: category}) do
69
-      {:ok, record} -> Jason.encode(record)
70
-      {:error, _error} = err -> err
71
-    end
64
+    Koype.Repo.fetch_or_create(Koype.Repo.Category, category, %{name: category})
72 65
   end
73 66
 
74 67
   def process_property(_name, value), do: {:ok, value}
75 68
 
76
-  @doc "Takes and transforms values of a map inline"
69
+  @doc "Takes and transforms values of a map inline."
77 70
   @spec process_properties(props :: Map.t()) :: Map.t() | {:error, any()}
78 71
   def process_properties(props) do
79 72
     Enum.reduce_while(props, props, fn {key, value}, acc ->

+ 229
- 143
lib/indieweb/micropub/entry.ex View File

@@ -1,133 +1,224 @@
1 1
 defmodule IndieWeb.Micropub.Entry do
2 2
   @behaviour IndieWeb.Micropub.Content
3
-  @uploadable_keys ~w(photo video)
4
-  @uploadable_actions ~w(replace add update)
3
+  @uploadable_actions ~w(replace add)
5 4
 
6 5
   alias IndieWeb.Auth.Scope
7 6
   alias IndieWeb.Micropub
8 7
   alias IndieWeb.Micropub.Content
9 8
   alias Koype.Repo
10
-  alias Koype.Repo.Entry
11
-  alias Koype.Storage.ObjectData
9
+  alias Koype.Repo.Entry, as: Model
10
+
11
+  require Logger
12 12
 
13 13
   # NOTE: Find all referenced in this entry.
14 14
   # NOTE: Queue a job to send all of the mentions.
15
-  defp do_send_webmentions(entry) do
15
+  def send_webmentions(_) do
16 16
     :ok
17 17
   end
18 18
 
19
-  defp can_action_trigger_upload?(key),
20
-    do: Enum.member?(@uploadable_actions, key)
21
-
22
-  defp will_data_trigger_upload?(params),
23
-    do: Enum.any?(@uploadable_keys, fn key -> Map.has_key?(params, key) end)
19
+  defp can_action_trigger_upload?(key)
20
+  defp can_action_trigger_upload?(key), do: Enum.member?(@uploadable_actions, key)
24 21
 
25
-  defp do_update(action, entry, entry_data, data)
22
+  defp do_merge_value(properties, key, new_value)
26 23
 
27
-  defp do_update("replace", entry, _entry_data, data) do
28
-    {:ok, _entry} = Entry.update(entry, data)
29
-    :ok
24
+  defp do_merge_value(properties, key, new_value) when is_binary(new_value) do
25
+    do_merge_value(properties, key, [new_value])
30 26
   end
31 27
 
32
-  defp do_update("add", entry, entry_data, data) do
33
-    added_properties = Map.keys(data)
28
+  defp do_merge_value(properties, key, new_value) when is_list(new_value) do
29
+    current_value = Map.get(properties, key, [])
34 30
 
35
-    new_entry_data =
36
-      Enum.map(entry_data, fn {key, value} ->
37
-        if Enum.member?(added_properties, key) do
38
-          cond do
39
-            is_binary(value) -> {key, [value] ++ data[key]}
40
-            is_list(value) -> {key, value ++ data[key]}
41
-            true -> {key, value}
42
-          end
31
+    cond do
32
+      key == "content" ->
33
+        if Map.has_key?(properties["content"], "html") do
34
+          Map.put(properties, "content", %{
35
+            "html" => current_value["html"] ++ new_value,
36
+            "value" => current_value["value"] ++ new_value
37
+          })
43 38
         else
44
-          {key, value}
39
+          Map.put(properties, "content", %{"value" => current_value["value"] ++ new_value})
45 40
         end
46
-      end)
47
-      |> Map.new()
48 41
 
49
-    {:ok, _entry} = Entry.update(entry, new_entry_data)
50
-    :ok
42
+      true ->
43
+        Map.put(properties, key, current_value ++ new_value)
44
+    end
51 45
   end
52 46
 
53
-  defp do_update("delete", entry, entry_data, data) when is_list(data) do
54
-    new_entry_data = Map.drop(entry_data, data)
55
-    {:ok, _entry} = Entry.update(entry, new_entry_data)
56
-    :ok
47
+  defp do_drop_value(properties, "content", value) when is_binary(value) do
48
+    do_drop_value(properties, "content", [value])
57 49
   end
58 50
 
59
-  defp do_update("delete", entry, entry_data, data) when is_map(data) do
60
-    deleted_properties = Map.keys(data)
51
+  defp do_drop_value(properties, "content", value) when is_map(value) do
52
+    Map.put(properties, "content", %{"value" => ""})
53
+  end
54
+
55
+  defp do_drop_value(properties, "content", value) when is_list(value) do
56
+    existing_values = Map.get(properties["content"], "value", [])
61 57
 
62
-    new_entry_data =
63
-      Enum.map(entry_data, fn {key, values} ->
64
-        if Enum.member?(deleted_properties, key) do
65
-          trimmed_props = Enum.reject(values, fn value -> Enum.member?(data[key], value) end)
58
+    Map.put(
59
+      properties,
60
+      "content",
61
+      Map.put(properties["content"], "value", existing_values -- value)
62
+    )
63
+  end
66 64
 
67
-          {key, trimmed_props}
65
+  defp do_drop_value(properties, key, value) when is_binary(value) do
66
+    do_drop_value(properties, key, [value])
67
+  end
68
+
69
+  defp do_drop_value(properties, key, value) when is_list(value) do
70
+    existing_values = Map.get(properties, key, [])
71
+    Map.put(properties, key, existing_values -- value)
72
+  end
73
+
74
+  defp do_enact_update_action(action, model, properties, action_data)
75
+
76
+  defp do_enact_update_action("replace", model, properties, action_data) do
77
+    result =
78
+      Enum.reduce_while(action_data, properties, fn {key, value}, updated_properties ->
79
+        if Koype.Storage.does_property_need_upload?(key) do
80
+          with(
81
+            :ok <-
82
+              Koype.Storage.destroy_property_for_record(
83
+                {key, Map.get(updated_properties, key, [])},
84
+                model
85
+              ),
86
+            {^key, uploaded_values} <- Koype.Storage.upload_property_for_record({key, action_data[key]}, model)
87
+          ) do
88
+            {:cont, {:ok, Map.put(updated_properties, key, uploaded_values)}}
89
+          else
90
+            {:error, error} -> {:halt, {:error, key: key, error: error}}
91
+          end
68 92
         else
69
-          {key, values}
93
+          {:cont, {:ok, Map.put(updated_properties, key, value)}}
70 94
         end
71 95
       end)
72
-      |> Map.new()
73 96
 
74
-    {:ok, _entry} = Entry.update(entry, new_entry_data)
75
-    :ok
97
+    case result do
98
+      {:ok, properties} -> Model.update(model, properties)
99
+      _ -> result
100
+    end
76 101
   end
77 102
 
78
-  # TODO: Handle rollback cleanly if tx fails
79
-  defp do_update_entry(entry, scope: scope, params: params) do
80
-    {:ok, entry_data} = ObjectData.fetch(entry)
103
+  defp do_enact_update_action("add", model, properties, action_data) when is_map(action_data) do
104
+    result =
105
+      Enum.reduce_while(action_data, {:ok, properties}, fn {key, value}, {:ok, updated_properties} ->
106
+        if Koype.Storage.does_property_need_upload?(key) do
107
+          case Koype.Storage.upload_property_for_record({key, value}, model) do
108
+            {:ok, {^key, uploaded_values}} ->
109
+              {:cont, {:ok, do_merge_value(updated_properties, key, uploaded_values)}}
110
+
111
+            {:error, error} ->
112
+              {:halt, {:error, key: key, error: error}}
113
+          end
114
+        else
115
+          {:cont, {:ok, do_merge_value(updated_properties, key, value)}}
116
+        end
117
+      end)
81 118
 
82
-    actionables = Micropub.actionable(params)
83
-    actions = Map.keys(actionables)
119
+    case result do
120
+      {:ok, properties} -> Model.update(model, properties)
121
+      _ -> result
122
+    end
123
+  end
84 124
 
125
+  defp do_enact_update_action("delete", model, properties, action_data) when is_map(action_data) do
85 126
     result =
86
-      Repo.transaction(fn ->
87
-        Enum.each(actions, fn action ->
88
-          data = actionables[action]
89
-
90
-          if can_action_trigger_upload?(action) && will_data_trigger_upload?(data) do
91
-            if Scope.can_upload?(scope) do
92
-              do_update(action, entry, entry_data, data)
93
-            else
94
-              Repo.rollback(:scope_missing)
95
-            end
96
-          else
97
-            do_update(action, entry, entry_data, data)
127
+      Enum.reduce_while(action_data, {:ok, properties}, fn {key, value}, {:ok, updated_properties} ->
128
+        if Koype.Storage.does_property_need_upload?(key) do
129
+          case Koype.Storage.destroy_property_for_record({key, value}, model) do
130
+            :ok -> {:cont, {:ok, do_drop_value(updated_properties, key, value)}}
131
+            {:error, error} -> {:halt, {:error, key: key, error: error}}
98 132
           end
99
-        end)
133
+        else
134
+          {:cont, {:ok, do_drop_value(updated_properties, key, value)}}
135
+        end
100 136
       end)
101 137
 
102 138
     case result do
103
-      {:ok, :ok} -> :ok
139
+      {:ok, properties} -> Model.update(model, properties)
104 140
       _ -> result
105 141
     end
106 142
   end
107 143
 
108
-  defp do_assoc_category_to_entry(entry, categories)
109
-  defp do_assoc_category_to_entry(entry, []), do: {:ok, entry}
144
+  defp do_enact_update_action("delete", model, properties, action_data) when is_list(action_data) do
145
+    result =
146
+      Enum.reduce_while(action_data, {:ok, model, properties}, fn key, {:ok, new_model, new_properties} ->
147
+        current_value = new_properties[key]
110 148
 
111
-  defp do_assoc_category_to_entry(entry, category) when is_binary(category) do
112
-    {:ok, json_category} = Jason.decode(category)
113
-    {:ok, record} = Koype.Repo.Category.fetch(json_category["id"])
149
+        with(
150
+          {:ok, refreshed_model} <-
151
+            do_enact_update_action("delete", new_model, new_properties, %{key => current_value}),
152
+          {:ok, refreshed_properties} <- Model.Json.find(refreshed_model)
153
+        ) do
154
+          {:cont, {:ok, refreshed_model, refreshed_properties}}
155
+        else
156
+          {:error, _} = err -> {:halt, err}
157
+        end
158
+      end)
114 159
 
115
-    preloaded_entry = Koype.Repo.preload(entry, :categories)
160
+    case result do
161
+      {:ok, refreshed_model, _} -> {:ok, refreshed_model}
162
+      _ -> result
163
+    end
164
+  end
116 165
 
117
-    preloaded_entry
166
+  # TODO: Handle rollback cleanly if tx fails
167
+  defp do_update_entry(model, scope: scope, params: params) do
168
+    case Model.Json.find(model) do
169
+      {:ok, properties} ->
170
+        actionable_data = Micropub.actionable(params)
171
+
172
+        Repo.transaction(fn ->
173
+          Enum.reduce_while(Map.keys(actionable_data), nil, fn action, _ ->
174
+            action_data = actionable_data[action]
175
+
176
+            if can_action_trigger_upload?(action) and Koype.Storage.will_trigger_upload?(action_data) and
177
+                 !Scope.can_upload?(scope) do
178
+              Logger.info(fn -> "Action '#{action}' required permissions to upload." end)
179
+              Repo.rollback(:scope_missing)
180
+            else
181
+              Logger.info(fn ->
182
+                "Invoking action #{action} on #{model.id} with #{inspect(action_data)}"
183
+              end)
184
+
185
+              case do_enact_update_action(action, model, properties, action_data) do
186
+                {:ok, model} -> {:cont, model}
187
+                {:error, error} -> Repo.rollback(error)
188
+              end
189
+            end
190
+          end)
191
+        end)
192
+
193
+      {:error, _} = error ->
194
+        error
195
+    end
196
+  end
197
+
198
+  defp do_assoc_category_to_entry(model, categories)
199
+  defp do_assoc_category_to_entry(model, []), do: {:ok, model}
200
+
201
+  defp do_assoc_category_to_entry(model, category) when is_binary(category) do
202
+    case Koype.Repo.fetch_or_create(Koype.Repo.Category, category, %{name: category}) do
203
+      {:ok, record} -> do_assoc_category_to_entry(model, record)
204
+      {:error, _} = error -> error
205
+    end
206
+  end
207
+
208
+  defp do_assoc_category_to_entry(model, %Koype.Repo.Category{} = record) do
209
+    preloaded_model = Koype.Repo.preload(model, :categories)
210
+    new_category_list = [record] ++ preloaded_model.categories
211
+
212
+    preloaded_model
118 213
     |> Ecto.Changeset.change()
119
-    |> Ecto.Changeset.put_assoc(
120
-      :categories,
121
-      [record] ++ preloaded_entry.categories
122
-    )
214
+    |> Ecto.Changeset.put_assoc(:categories, new_category_list)
123 215
     |> Koype.Repo.update()
124 216
   end
125 217
 
126
-  @spec do_assoc_category_to_entry(entry :: Entry.t(), categories :: List.t()) ::
127
-          {:ok, Entry.t()} | {:error, any()}
128
-  defp do_assoc_category_to_entry(entry, categories) when is_list(categories) do
129
-    Enum.reduce_while(categories, {:ok, entry}, fn category, {:ok, entry} ->
130
-      case do_assoc_category_to_entry(entry, category) do
218
+  @spec do_assoc_category_to_entry(entry :: Entry.t(), categories :: List.t()) :: {:ok, Entry.t()} | {:error, any()}
219
+  defp do_assoc_category_to_entry(model, categories) when is_list(categories) do
220
+    Enum.reduce_while(categories, {:error, :failed_assoc_category}, fn category, _ ->
221
+      case do_assoc_category_to_entry(model, category) do
131 222
         {:ok, record} -> {:cont, {:ok, record}}
132 223
         {:error, cs} -> {:halt, {:error, cs.errors}}
133 224
       end
@@ -136,24 +227,52 @@ defmodule IndieWeb.Micropub.Entry do
136 227
 
137 228
   def invoke(action, arguments)
138 229
 
230
+  @impl true
231
+  def invoke("update", scope: scope, params: [reserved: params, content: _]) do
232
+    if Enum.member?(scope, "update") do
233
+      url = params["url"]
234
+
235
+      case Model.resolve_from_uri(url) do
236
+        nil ->
237
+          {:error, :not_found}
238
+
239
+        {:ok, model} ->
240
+          with(
241
+            {:ok, refreshed_model} <- do_update_entry(model, scope: scope, params: params),
242
+            :ok <- send_webmentions(refreshed_model)
243
+          ) do
244
+            {:ok, state: :updated, model: refreshed_model}
245
+          else
246
+            {:error, _} = error -> error
247
+          end
248
+
249
+        {:error, _} = error ->
250
+          error
251
+      end
252
+    else
253
+      {:error, :scope_missing}
254
+    end
255
+  end
256
+
139 257
   @impl true
140 258
   def invoke("create", scope: scope, params: [reserved: rp, content: cp]) do
141 259
     if Enum.member?(scope, "create") do
142
-      full_params = Content.process_properties(Map.merge(rp, cp))
260
+      params = Map.merge(rp, cp)
143 261
 
144
-      if will_data_trigger_upload?(full_params) && !Scope.can_upload?(scope) do
262
+      if Koype.Storage.will_trigger_upload?(params) && !Scope.can_upload?(scope) do
145 263
         {:error, :scope_missing}
146 264
       else
265
+        categories = Map.get(params, "category", [])
266
+
147 267
         with(
148
-          {:ok, entry} <- Entry.create("entry", full_params),
149
-          {:ok, entry} <- do_assoc_category_to_entry(entry, Map.get(full_params, "category", [])),
150
-          :ok <- do_send_webmentions(entry)
268
+          full_params when is_map(full_params) <- Content.process_properties(params),
269
+          {:ok, model} <- Model.create(full_params),
270
+          {:ok, reloaded_model} <- do_assoc_category_to_entry(model, categories),
271
+          :ok <- send_webmentions(model)
151 272
         ) do
152
-          {:ok, :created, entry}
273
+          {:ok, state: :created, model: reloaded_model}
153 274
         else
154
-          {:error, cs} ->
155
-            Logger.warning("Failed to create entry: #{IO.inspect(cs.errors)}")
156
-            {:error, :not_created}
275
+          {:error, _} = error -> error
157 276
         end
158 277
       end
159 278
     else
@@ -161,49 +280,21 @@ defmodule IndieWeb.Micropub.Entry do
161 280
     end
162 281
   end
163 282
 
283
+  @impl true
164 284
   def invoke("delete", scope: scope, params: [reserved: reserved_params, content: _]) do
165 285
     if Enum.member?(scope, "delete") do
166 286
       url = Map.get(reserved_params, "url")
167 287
 
168
-      result =
169
-        case Entry.resolve_from_uri(url) do
170
-          nil ->
171
-            {:error, :not_found}
172
-
173
-          {:ok, entry} ->
174
-            with(
175
-              :ok <- Entry.delete(entry),
176
-              :ok <- do_send_webmentions(entry)
177
-            ) do
178
-              :ok
179
-            else
180
-              {:error, _} = error -> error
181
-            end
182
-        end
183
-
184
-      case result do
185
-        :ok -> {:ok, :deleted}
186
-        {:error, :not_found} -> {:error, :entry_not_found}
187
-      end
188
-    else
189
-      {:error, :scope_missing}
190
-    end
191
-  end
192
-
193
-  def invoke("update", scope: scope, params: [reserved: params, content: _]) do
194
-    if Enum.member?(scope, "update") do
195
-      url = params["url"]
196
-
197
-      case Entry.resolve_from_uri(url) do
288
+      case Model.resolve_from_uri(url) do
198 289
         nil ->
199
-          {:error, :entry_not_found}
290
+          {:error, :not_found}
200 291
 
201 292
         {:ok, entry} ->
202 293
           with(
203
-            :ok <- do_update_entry(entry, scope: scope, params: params),
204
-            :ok <- do_send_webmentions(entry)
294
+            {:ok, record} <- Model.delete(entry),
295
+            :ok <- send_webmentions(entry)
205 296
           ) do
206
-            {:ok, :updated, Koype.Repo.get(Koype.Repo.Entry, entry.id)}
297
+            {:ok, state: :deleted, model: record}
207 298
           else
208 299
             {:error, _} = error -> error
209 300
           end
@@ -213,29 +304,24 @@ defmodule IndieWeb.Micropub.Entry do
213 304
     end
214 305
   end
215 306
 
216
-  def invoke("undelete", scope: scope, params: [reserved: reserved_params, content: _]) do
307
+  @impl true
308
+  def invoke("undelete", scope: scope, params: [reserved: params, content: _]) do
217 309
     if Enum.member?(scope, "undelete") do
218
-      url = Map.get(reserved_params, "url")
310
+      url = Map.get(params, "url")
219 311
 
220
-      result =
221
-        case Entry.resolve_from_uri(url) do
222
-          nil ->
223
-            {:error, :not_found}
224
-
225
-          {:ok, entry} ->
226
-            with(
227
-              :ok <- Entry.undelete(entry),
228
-              :ok <- do_send_webmentions(entry)
229
-            ) do
230
-              :ok
231
-            else
232
-              {:error, _} = error -> error
233
-            end
234
-        end
312
+      case Model.resolve_from_uri(url) do
313
+        nil ->
314
+          {:error, :not_found}
235 315
 
236
-      case result do
237
-        {:ok, entry} -> {:ok, :undeleted, entry}
238
-        {:error, :not_found} -> {:error, :entry_not_found}
316
+        {:ok, model} ->
317
+          with(
318
+            {:ok, record} <- Model.undelete(model),
319
+            :ok <- send_webmentions(record)
320
+          ) do
321
+            {:ok, state: :undeleted, model: record}
322
+          else
323
+            {:error, _} = error -> error
324
+          end
239 325
       end
240 326
     else
241 327
       {:error, :scope_missing_for_undelete}
@@ -243,5 +329,5 @@ defmodule IndieWeb.Micropub.Entry do
243 329
   end
244 330
 
245 331
   @impl true
246
-  def supports?(action), do: Enum.member?(~w(create update delete undelete post-process), action)
332
+  def supports?(action), do: Enum.member?(~w(create update delete undelete), action)
247 333
 end

+ 171
- 178
lib/indieweb/post.ex View File

@@ -2,7 +2,8 @@ defmodule IndieWeb.Post do
2 2
   alias Phoenix.HTML.SimplifiedHelpers.Truncate, as: T
3 3
 
4 4
   @properties_to_kind %{
5
-    "location" => :checkin,
5
+    "checkin" => :checkin,
6
+    "audio" => :audio,
6 7
     "in-reply-to" => :reply,
7 8
     "in_reply_to" => :reply,
8 9
     "like-of" => :like,
@@ -18,8 +19,6 @@ defmodule IndieWeb.Post do
18 19
     "name" => :article
19 20
   }
20 21
 
21
-  def response_types, do: ~w(reply listen like bookmark repost read follow article note)a
22
-
23 22
   def known_types do
24 23
     [
25 24
       %{type: "article", name: "Article", names: "Articles"},
@@ -34,184 +33,73 @@ defmodule IndieWeb.Post do
34 33
       %{type: "donation", name: "Donation", names: "Donations"},
35 34
       %{type: "payment", name: "Payment", names: "Payments"},
36 35
       %{type: "event", name: "Event", names: "Events"},
37
-      # %{type: "exercise", name: "Workout", names: "Workouts"},
36
+      %{type: "exercise", name: "Workout", names: "Workouts"},
38 37
       %{type: "follow", name: "Follow", names: "Follows"},
39
-      # %{type: "food", name: "Food", names: "Meals"},
38
+      %{type: "food", name: "Food", names: "Meals"},
40 39
       %{type: "gameplay", name: "Game Play", names: "Game Plays"},
41
-      # %{type: "issue", name: "Issue", names: "Issues"},
40
+      %{type: "issue", name: "Issue", names: "Issues"},
42 41
       %{type: "photo", name: "Photo", names: "Photos"},
43
-      # %{type: "presentation", name: "Presentation", names: "Presentations"},
44
-      # %{type: "quotation", name: "Quotation", names: "Quotations"},
42
+      %{type: "presentation", name: "Presentation", names: "Presentations"},
43
+      %{type: "quotation", name: "Quotation", names: "Quotations"},
45 44
       %{type: "read", name: "Read", names: "Reads"},
46
-      # %{type: "sleep", name: "Sleep", names: "Sleeps"},
47
-      # %{type: "venue", name: "Venue", names: "Venues"},
45
+      %{type: "sleep", name: "Sleep", names: "Sleeps"},
46
+      %{type: "venue", name: "Venue", names: "Venues"},
48 47
       %{type: "watch", name: "Watch", names: "Watches"},
49 48
       %{type: "video", name: "Video", names: "Videos"},
50 49
       %{type: "rsvp", name: "RSVP", names: "RSVPs"}
51 50
     ]
52 51
   end
53 52
 
54
-  def extract_post_types(mf2) do
55
-    mf2
56
-    |> Map.keys()
57
-    |> Enum.map(fn
58
-      key when is_atom(key) -> Map.get(@properties_to_kind, key |> Atom.to_string(), nil)
59
-      key when is_binary(key) -> Map.get(@properties_to_kind, key, nil)
60
-    end)
61
-    |> Enum.reject(&is_nil/1)
62
-  end
63
-
64
-  defp detect_note(data) do
65
-    cond do
66
-      is_binary(data[:content]) && String.length(data[:content]) == 0 ->
67
-        false
68
-
69
-      is_binary(data[:summary]) && String.length(data[:summary]) == 0 ->
70
-        false
71
-
72
-      !is_nil(data[:name]) && is_list(data[:name]) && String.length(List.first(data[:name])) != 0 ->
73
-        false
74
-
75
-      !is_nil(data[:name]) && String.length(data[:name]) != 0 ->
76
-        false
77
-
78
-      true ->
79
-        true
80
-    end
81
-  end
82
-
83
-  defp detect_article(data) do
84
-    name =
85
-      if is_binary(Map.get(data, :name, "")) do
86
-        String.trim(Map.get(data, :name, ""))
87
-      else
88
-        Map.get(data, :name)
89
-      end
90
-
91
-    content =
92
-      if is_binary(Map.get(data, :content, "")) do
93
-        String.trim(Map.get(data, :content, ""))
94
-      else
95
-        Map.get(data, :content)
96
-      end
97
-
98
-    plain_content =
99
-      if is_map(content) do
100
-        content[:value]
101
-      else
102
-        content
103
-      end
104
-
105
-    cond do
106
-      is_binary(name) && String.length(name) != 0 ->
107
-        true
108
-
109
-      is_list(name) && String.length(List.first(name)) != 0 ->
110
-        true
111
-
112
-      is_binary(name) && is_binary(content) && !String.starts_with?(content, name) ->
113
-        true
114
-
115
-      is_list(name) && is_list(plain_content) &&
116
-          !String.starts_with?(List.first(plain_content), name[0]) ->
117
-        true
118
-
119
-      is_list(name) && is_binary(plain_content) && !String.starts_with?(plain_content, name[0]) ->
120
-        true
121
-
122
-      is_binary(name) && is_list(plain_content) &&
123
-          !String.starts_with?(List.first(plain_content), name) ->
124
-        true
125
-
126
-      is_binary(name) && is_binary(plain_content) && !String.starts_with?(plain_content, name) ->
127
-        true
128
-
129
-      true ->
130
-        false
131
-    end
132
-  end
133
-
134
-  def determine_type(mf2)
135
-
136
-  def determine_type(%{properties: properties} = _mf2), do: determine_type(properties)
137
-
138
-  def determine_type(mf2) do
139
-    types = extract_post_types(mf2)
53
+  def response_types, do: ~w(reply listen like bookmark repost read follow)a
140 54
 
141
-    case types do
142
-      [] -> {:ok, [:note]}
143
-      _ -> {:ok, types}
144
-    end
55
+  def is_response_type?(type) do
56
+    Enum.member?(response_types(), type)
145 57
   end
146 58
 
147
-  def get_properties_for_type(type, %{properties: properties} = _data),
148
-    do: get_properties_for_type(type, properties)
149
-
150
-  def get_properties_for_type(:video, %{video: video} = _data), do: video
151
-  def get_properties_for_type(:photo, %{photo: photo} = _data), do: photo
152
-  def get_properties_for_type(:like, %{"like-of": uri} = _data), do: uri
153
-  def get_properties_for_type(:repost, %{"repost-of": uri} = _data), do: uri
154
-
155
-  def get_properties_for_type(:reply, %{"in-reply-to": uri, content: _content} = _data),
156
-    do: uri
157
-
158
-  def get_properties_for_type(:bookmark, %{"bookmark-of": bookmark} = _data),
159
-    do: bookmark
160
-
161
-  def get_properties_for_type(:checkin, %{location: location} = _data) when is_binary(location) do
162
-    if String.starts_with?(location, "geo:") do
163
-      regex = ~r/geo\:c(?<lat>\d+)\,c(?<lng>\d+)/
164
-      %{lat: lat, lng: lng} = Regex.named_captures(regex, location)
165
-
166
-      %{longitude: lng, latitude: lat}
59
+  @doc "Determines the types exposed by the provided properties."
60
+  @spec determine_type(properties :: Map.t()) :: Atom.t()
61
+  def determine_type(properties) when is_map(properties) do
62
+    types =
63
+      properties
64
+      |> Map.keys()
65
+      |> Enum.map(fn
66
+        key when is_binary(key) -> Map.get(@properties_to_kind, key, nil)
67
+        key when is_atom(key) -> Map.get(@properties_to_kind, Atom.to_string(key), nil)
68
+      end)
69
+      |> Enum.reject(&is_nil/1)
70
+
71
+    if types == [] do
72
+      [:note]
167 73
     else
168
-      location
74
+      types
169 75
     end
170 76
   end
171 77
 
172
-  def get_properties_for_type(:checkin, data), do: Map.take(data, ~w(location location-visibility)a)
78
+  @doc "Extract properties specific to a particular post type."
79
+  @spec get_properties_for_type(type :: Atom.t(), properties :: Map.t()) :: Map.t()
80
+  def get_properties_for_type(type, properties)
81
+  def get_properties_for_type(:note, properties), do: get_properties_for_type(:entry, properties)
173 82
 
174
-  def get_properties_for_type(:event, data),
175
-    do: Map.take(data, ~w(location location-visibility start end duration organizer)a)
176
-
177
-  def get_properties_for_type(:note, _data), do: %{}
178
-  def get_properties_for_type(:article, data), do: Map.take(data, ~w(summary)a)
179
-
180
-  def get_properties_for_type(:entry, data),
83
+  def get_properties_for_type(:entry, properties),
181 84
     do:
182
-      Map.take(data, ~w(name content data category published updated syndication)a)
183
-      |> (fn map ->
184
-            if is_nil(map[:category]) do
185
-              map
186
-            else
187
-              Map.put(
188
-                map,
189
-                :category,
190
-                Enum.map(map[:category], fn category_json ->
191
-                  {:ok, category} = Jason.decode(category_json)
192
-                  {:ok, record} = Koype.Repo.Category.fetch(category["id"])
193
-                  record
194
-                end)
195
-              )
196
-            end
197
-          end).()
85
+      Map.take(
86
+        properties,
87
+        ~w(name summary content published updated author category url uid syndication)a
88
+      )
198 89
 
199 90
   def determine_dominant_type(type, properties)
200 91
 
201
-  def determine_dominant_type(types, %{properties: properties}),
202
-    do: determine_dominant_type(types, properties)
203
-
204 92
   def determine_dominant_type(types, properties) do
205 93
     cond do
206
-      :reply in types ->
207
-        :reply
208
-
209 94
       :event in types ->
210 95
         :event
211 96
 
212
-      :rsvp in types ->
97
+      [:rsvp, :reply] in types ->
213 98
         :rsvp
214 99
 
100
+      :reply in types ->
101
+        :reply
102
+
215 103
       :checkin in types ->
216 104
         :checkin
217 105
 
@@ -242,46 +130,151 @@ defmodule IndieWeb.Post do
242 130
   end
243 131
 
244 132
   def determine_title(type, data)
245
-  def determine_title(_, %{name: name} = _data), do: name
246 133
 
247
-  def determine_title(:photo, %{name: name} = _data), do: "Photo: #{name}"
134
+  def determine_title(:audio, %{"name" => name, "audio" => audio})
135
+      when is_list(audio) and length(audio) == 1 and is_binary(name) do
136
+    name <> " - One Audio"
137
+  end
138
+
139
+  def determine_title(:audio, %{"name" => name, "audio" => audio})
140
+      when is_list(audio) and is_binary(name) do
141
+    name <> " - #{length(audio)} Audio"
142
+  end
143
+
144
+  def determine_title(:audio, %{"content" => content, "name" => nil, "audio" => audio})
145
+      when is_list(audio) do
146
+    Enum.join(
147
+      [T.truncate(content["value"]), "-", determine_title(:audio, %{"audio" => audio})],
148
+      " "
149
+    )
150
+  end
151
+
152
+  def determine_title(:audio, %{"audio" => audio}) when is_list(audio) and length(audio) == 1 do
153
+    "One Audio"
154
+  end
155
+
156
+  def determine_title(:audio, %{"audio" => audio}) when is_list(audio) and length(audio) > 1 do
157
+    "#{length(audio)} Audio"
158
+  end
159
+
160
+  def determine_title(:photo, %{"name" => name, "photo" => photo})
161
+      when is_list(photo) and length(photo) == 1 and is_binary(name) do
162
+    name <> " - One Photo"
163
+  end
164
+
165
+  def determine_title(:photo, %{"name" => name, "photo" => photo})
166
+      when is_list(photo) and is_binary(name) do
167
+    name <> " - #{length(photo)} Photos"
168
+  end
169
+
170
+  def determine_title(:photo, %{"content" => content, "name" => nil, "photo" => photo})
171
+      when is_list(photo) do
172
+    Enum.join(
173
+      [T.truncate(content["value"]), "-", determine_title(:photo, %{"photo" => photo})],
174
+      " "
175
+    )
176
+  end
177
+
178
+  def determine_title(:photo, %{"photo" => photo}) when is_list(photo) and length(photo) == 1 do
179
+    "One Photo"
180
+  end
181
+
182
+  def determine_title(:photo, %{"photo" => photo}) when is_list(photo) and length(photo) > 1 do
183
+    "#{length(photo)} Photos"
184
+  end
248 185
 
249
-  def determine_title(:photo, %{content: content} = _data),
250
-    do: "Photo: #{content |> T.truncate()}"
186
+  def determine_title(:video, %{"name" => name, "video" => video})
187
+      when is_list(video) and length(video) == 1 and is_binary(name) do
188
+    name <> " - One Video"
189
+  end
190
+
191
+  def determine_title(:video, %{"name" => name, "video" => video})
192
+      when is_list(video) and is_binary(name) do
193
+    name <> " - #{length(video)} Videos"
194
+  end
195
+
196
+  def determine_title(:video, %{"content" => content, "video" => video, "name" => nil})
197
+      when is_list(video) do
198
+    Enum.join(
199
+      [T.truncate(content["value"]), "-", determine_title(:video, %{"video" => video})],
200
+      " "
201
+    )
202
+  end
203
+
204
+  def determine_title(:video, %{"video" => video}) when is_list(video) and length(video) == 1 do
205
+    "One Video"
206
+  end
251 207
 
252
-  def determine_title(:bookmark, %{content: content} = _data),
253
-    do: "Bookmarked: #{content |> T.truncate()}"
208
+  def determine_title(:video, %{"video" => video}) when is_list(video) and length(video) > 1 do
209
+    "#{length(video)} Videos"
210
+  end
254 211
 
255
-  def determine_title(:bookmark, %{:"bookmark-of" => uri} = _data),
256
-    do: "Bookmarked: #{uri |> T.truncate()}"
212
+  def determine_title(:note, %{"content" => content}) when is_map(content),
213
+    do: T.truncate(content["value"])
257 214
 
258
-  def determine_title(:bookmark, %{bookmark: uri} = _data),
259
-    do: "Bookmarked: #{uri |> T.truncate()}"
215
+  def determine_title(:bookmark, %{"content" => content}) when is_map(content),
216
+    do: "Bookmarked \"#{T.truncate(content["value"])}\""
260 217
 
261
-  def determine_title(:note, %{content: content} = _data) when is_map(content),
262
-    do: "Note: #{content[:value] |> T.truncate()}"
218
+  def determine_title(:bookmark, %{"bookmark-of" => [uri]}),
219
+    do: "Bookmarked #{do_micro_url(uri)}"
263 220
 
264
-  def determine_title(:note, %{content: content} = _data) when is_list(content),
265
-    do: "Note: #{content |> List.first() |> T.truncate()}"
221
+  def determine_title(:like, %{"like-of" => [uri]}),
222
+    do: "Liked #{do_micro_url(uri)}"
266 223
 
267
-  def determine_title(:like, %{:like => uri} = _data), do: "Liked: #{uri |> T.truncate()}"
268
-  def determine_title(:like, %{"like" => uri} = _data), do: "Liked: #{uri |> T.truncate()}"
224
+  def determine_title(:repost, %{"repost-of" => [uri]}),
225
+    do: "Reposted #{do_micro_url(uri)}"
269 226
 
270
-  def determine_title(:repost, %{:repost => uri} = _data) when is_list(uri),
271
-    do: "Reposted: #{uri |> List.first() |> T.truncate()}"
227
+  def determine_title(:reply, %{"in-reply-to" => [uri]}),
228
+    do: "Replied to #{do_micro_url(uri)}"
272 229
 
273
-  def determine_title(:repost, %{:"repost-of" => uri} = _data),
274
-    do: "Reposted: #{uri |> T.truncate()}"
230
+  def determine_title(_, %{"name" => name}), do: T.truncate(name)
275 231
 
276
-  def determine_title(:repost, %{"repost" => uri} = _data),
277
-    do: "Reposted: #{uri |> T.truncate()}"
232
+  def determine_title(_, %{"content" => content}) when is_map(content),
233
+    do: T.truncate(content["value"])
278 234
 
279
-  def determine_title(:reply, %{:"in-reply-to" => uri} = _data),
280
-    do: "Replied: #{uri |> T.truncate()}"
235
+  def determine_title(_, %{"content" => content}) when is_binary(content), do: T.truncate(content)
281 236
 
282 237
   def determine_title(_type, _data), do: "Entry"
283 238
 
284
-  def is_response_type?(type) do
285
-    Enum.member?(response_types, type)
239
+  defp do_micro_url(uri) do
240
+    "#{
241
+      URI.parse(uri)
242
+      |> Map.take([:host, :path])
243
+      |> Map.values()
244
+      |> Enum.reject(&is_nil/1)
245
+      |> Enum.join("/")
246
+    }"
247
+  end
248
+
249
+  defp detect_note(data) do
250
+    cond do
251
+      is_map(data["content"]) and String.length(data["content"]["value"]) == 0 ->
252
+        false
253
+
254
+      is_binary(data["content"]) and String.length(data["content"]) == 0 ->
255
+        false
256
+
257
+      is_binary(data["summary"]) && String.length(data["summary"]) == 0 ->
258
+        false
259
+
260
+      is_binary(data["name"]) && String.length(data["name"]) != 0 ->
261
+        false
262
+
263
+      true ->
264
+        true
265
+    end
266
+  end
267
+
268
+  defp detect_article(data) do
269
+    cond do
270
+      is_binary(data["name"]) and !String.starts_with?(data["content"]["value"], data["name"]) ->
271
+        true
272
+
273
+      is_binary(data["summary"]) ->
274
+        true
275
+
276
+      true ->
277
+        false
278
+    end
286 279
   end
287 280
 end

+ 12
- 1
lib/indieweb/relme.ex View File

@@ -17,10 +17,21 @@ defmodule IndieWeb.RelMe do
17 17
         !Enum.member?(left_mes, right_link) -> {:error, :not_found_on_left}
18 18
         true -> :ok
19 19
       end
20
+    else
21
+      {:error, _} = error -> error
20 22
     end
21 23
   end
22 24
 
23 25
   def all, do: Repo.all(from(r in RelMe, select: r.uri))
26
+  def find(uri), do: Repo.one(from(r in RelMe, where: r.uri == ^uri))
27
+
24 28
   def add(uri), do: Repo.insert(RelMe.changeset(%RelMe{}, %{uri: uri}))
25
-  def remove(uri), do: Repo.delete(from(r in RelMe, where: r.uri == ^uri) |> Repo.one())
29
+
30
+  def remove(uri) do
31
+    case find(uri) do
32
+      nil -> {:error, :not_found}
33
+      {:error, _} = error -> error
34
+      %Koype.Repo.RelMe{} = record -> Repo.delete(record)
35
+    end
36
+  end
26 37
 end

+ 23
- 28
lib/indieweb/webmention.ex View File

@@ -59,44 +59,40 @@ defmodule IndieWeb.Webmention do
59 59
       {:ok, model} <- resolve_target(args[:target])
60 60
     ) do
61 61
       Logger.info("Webmention info checks out. Beginning ingestion...")
62
-      do_create_webmention_process_job(args ++ [model: model])
62
+      do_create_webmention_job(args ++ [model: model])
63 63
     else
64 64
       {:error, _} = error -> error
65 65
     end
66 66
   end
67 67
 
68 68
   # NOTE: Move logic into a GenServer.
69
-  defp do_create_webmention_process_job(args) do
69
+  defp do_create_webmention_job(args) do
70 70
     Logger.info("Fetching MF2 info of #{args[:source]}...")
71 71
 
72 72
     case IndieWeb.MF2.Remote.fetch(args[:source]) do
73
-      {:ok, %{items: items} = mf2} ->
73
+      {:ok, mf2} ->
74
+        entry_mf2 = IndieWeb.MF2.get_format(mf2, :entry)
75
+        types = IndieWeb.Post.determine_type(entry_mf2[:properties])
76
+        type = IndieWeb.Post.determine_dominant_type(types, entry_mf2[:properties])
77
+
78
+        Logger.info(fn ->
79
+          "Detected #{args[:source]} to have #{inspect(types)} types and be a #{type} at its base."
80
+        end)
81
+
74 82
         with(
75
-          entry_mf2 <- IndieWeb.MF2.get_format(mf2, :entry),
76
-          types <- IndieWeb.Post.extract_post_types(entry_mf2[:properties]),
77
-          type <- IndieWeb.Post.determine_dominant_type(types, entry_mf2[:properties])
83
+          {:ok, webmention_model} <-
84
+            Koype.Repo.Webmention.create(
85
+              source: args[:source],
86
+              target: args[:target],
87
+              type: Atom.to_string(type)
88
+            ),
89
+          {:ok, _} <- Koype.Storage.Json.persist(webmention_model, mf2)
78 90
         ) do
79
-          if IndieWeb.Post.is_response_type?(type) do
80
-            with(
81
-              {:ok, webmention_model} <-
82
-                Koype.Repo.Webmention.create(
83
-                  source: args[:source],
84
-                  target: args[:target],
85
-                  type: Atom.to_string(type)
86
-                ),
87
-              {:ok, _webmention_mf2_obj} <- Koype.Storage.ObjectData.craft(webmention_model, mf2)
88
-            ) do
89
-              {:ok, webmention_model}
90
-            else
91
-              {:error, _} = error ->
92
-                IndieWeb.MF2.Remote.flush(args[:source])
93
-                error
94
-            end
95
-          else
91
+          {:ok, webmention_model}
92
+        else
93
+          {:error, _} = error ->
96 94
             IndieWeb.MF2.Remote.flush(args[:source])
97
-            Logger.warn("MF2 of #{args[:source]} was not usable as a #{type} post type.")
98
-            {:error, :no_response_data}
99
-          end
95
+            error
100 96
         end
101 97
 
102 98
       {:error, _} = err ->
@@ -116,8 +112,7 @@ defmodule IndieWeb.Webmention do
116 112
     with(
117 113
       {:ok, mf2} <- Remote.fetch(site),
118 114
       rels <- Map.get(mf2, :rels, {:error, :no_relme_data_found}),
119
-      webmentions when is_list(webmentions) <-
120
-        Map.get(rels, :webmention, {:error, :no_webmention_uris_found})
115
+      webmentions when is_list(webmentions) <- Map.get(rels, :webmention, {:error, :no_webmention_uris_found})
121 116
     ) do
122 117
       if Enum.empty?(webmentions) do
123 118
         {:error, :no_webmention_uris_found}

+ 14
- 13
lib/page.ex View File

@@ -1,14 +1,17 @@
1 1
 defmodule Koype.Page do
2 2
   alias Koype.Http
3 3
 
4
+  @default_image "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
5
+
4 6
   # TODO: Add caching to this.
5 7
   def title(uri) do
6
-    with {:ok, %Http.Response{code: 200, body: body}} <- Http.get(uri) do
8
+    with {:ok, %Http.Response{code: code, body: body}} when code >= 200 and code < 300 <- Http.get(uri) do
7 9
       body
8 10
       |> Floki.find("title")
9 11
       |> Floki.text()
10 12
     else
11
-      {:error, _} -> uri
13
+      {:ok, _} -> URI.parse(uri).host
14
+      {:error, _} -> URI.parse(uri).host
12 15
     end
13 16
   end
14 17
 
@@ -16,18 +19,16 @@ defmodule Koype.Page do
16 19
     with({:ok, mf2} <- IndieWeb.MF2.Remote.fetch(uri)) do
17 20
       rels = mf2[:rels]
18 21
 
19
-      img_uri =
20
-        Enum.reduce_while(~w(icon apple-touch-icon mask-icon)a, nil, fn key, acc ->
21
-          if is_nil(Map.get(rels, key, nil)) do
22
-            {:cont, acc}
23
-          else
24
-            {:halt, Map.get(rels, key) |> List.first()}
25
-          end
26
-        end)
27
-
28
-      img_uri
22
+      Enum.reduce_while(~w(icon apple-touch-icon mask-icon)a, @default_image, fn key, acc ->
23
+        if is_nil(Map.get(rels, key, nil)) do
24
+          {:cont, acc}
25
+        else
26
+          {:halt, Map.get(rels, key) |> List.first()}
27
+        end
28
+      end)
29 29
     else
30
-      {:error, _} -> uri
30
+      {:ok, _} -> @default_image
31
+      {:error, _} -> @default_image
31 32
     end
32 33
   end
33 34
 end

+ 1
- 12
lib/profile.ex View File

@@ -55,18 +55,7 @@ defmodule Koype.Profile do
55 55
   def photo() do
56 56
     case get("photo") do
57 57
       nil -> nil
58
-      path -> Koype.Storage.Photo.url({path, :floating}, :original, signed: false)
58
+      path -> Koype.Storage.Image.url({path, :floating}, :original, signed: true)
59 59
     end
60 60
   end
61
-
62
-  def flagship_entry() do
63
-    case get("flagship_entry_id") do
64
-      nil -> nil
65
-      id -> Entry.fetch(id)
66
-    end
67
-  end
68
-
69
-  def set_flagship_entry(id) do
70
-    set("flagship_entry_id", id)
71
-  end
72 61
 end

+ 10
- 7
lib/repo.ex View File

@@ -21,12 +21,6 @@ defmodule Koype.Repo do
21 21
         {:error, _} ->
22 22
           columns
23 23
           |> Enum.reduce(schema, fn column, query ->
24
-            column_atom =
25
-              cond do
26
-                is_atom(column) -> column
27
-                is_binary(column) -> String.to_atom(column)
28
-              end
29
-
30 24
             if column == :id do
31 25
               if UUID.info(value) == {:ok, []} do
32 26
                 from(q in query, or_where: field(q, :id) == ^value)
@@ -34,7 +28,7 @@ defmodule Koype.Repo do
34 28
                 query
35 29
               end
36 30
             else
37
-              from(q in query, or_where: field(q, ^column_atom) == ^value)
31
+              from(q in query, or_where: field(q, ^column) == ^value)
38 32
             end
39 33
           end)
40 34
           |> one
@@ -81,4 +75,13 @@ defmodule Koype.Repo do
81 75
   def resolve_token_from_uri(%URI{path: path}, _) when is_nil(path), do: nil
82 76
 
83 77
   def count(module), do: aggregate(module, :count, :id)
78
+
79
+  @spec fetch_or_create(module :: Ecto.Schema.t(), lookup_value :: any(), values :: map()) ::
80
+          {:ok, any()} | {:error, any()}
81
+  def fetch_or_create(module, lookup_value, values) do
82
+    case module.fetch(lookup_value) do
83
+      {:error, :model_not_found} -> module.create(values)
84
+      {:ok, _} = result -> result
85
+    end
86
+  end
84 87
 end

+ 2
- 3
lib/repo/category.ex View File

@@ -4,7 +4,7 @@ defmodule Koype.Repo.Category do
4 4
   use Koype.Web, :model
5 5
 
6 6
   alias Koype.Repo
7
-  alias Koype.Repo.{Category, Entry}
7
+  alias Koype.Repo.Entry
8 8
   import Ecto.Changeset
9 9
 
10 10
   @primary_key {:id, :binary_id, autogenerate: true}
@@ -23,8 +23,7 @@ defmodule Koype.Repo.Category do
23 23
     timestamps()
24 24
   end
25 25
 
26
-  @doc false
27
-  defp changeset(category, attrs) do
26
+  def changeset(category, attrs) do
28 27
     category
29 28
     |> cast(attrs, @required_attrs ++ @optional_attrs)
30 29
     |> validate_required(@required_attrs)

+ 71
- 60
lib/repo/entry.ex View File

@@ -2,27 +2,66 @@ defmodule Koype.Repo.Entry do
2 2
   use Koype.Web, :model
3 3
   use Arc.Ecto.Schema
4 4
 
5
-  alias Koype.Storage.ObjectData
5
+  alias Koype.Storage.Json
6 6
   alias Koype.Repo.{Entry, Category}
7 7
   alias IndieWeb.Post
8 8
 
9 9
   @required_attrs ~w(type name)a
10
-  @optional_attrs ~w(object_data slug)a
11
-
12
-  @primary_key {:id, :binary_id, autogenerate: true}
13
-  @foreign_key_type :binary_id
10
+  @optional_attrs ~w(object_data slug deleted_at)a
14 11
 
15 12
   schema "entries" do
16 13
     field(:name, :string, null: false)
17
-    field(:object_data, ObjectData.Type)
18
-    field(:post_type, :string, default: "note", null: false)
14
+    field(:object_data, Json.Type, null: true)
15
+    field(:type, :string, default: "note", null: false)
19 16
     field(:slug, :string, default: nil)
20
-    field(:type, :string, null: false, default: "entry")
17
+    field(:deleted_at, :utc_datetime, default: nil, null: true)
21 18
     many_to_many(:categories, Category, join_through: "entries_categories")
22 19
 
23 20
     timestamps()
24 21
   end
25 22
 
23
+  defmodule Json do
24
+    @moduledoc "Represents structured data for an entry."
25
+    @enforce_keys [:data]
26
+    defstruct ~w(data properties)a
27
+    @type t :: %Json{data: Map.t(), properties: Map.t()}
28
+
29
+    # FIXME: Use struct to hold information.
30
+    def find(entry) do
31
+      with({:ok, data} <- Koype.Storage.Json.find(entry)) do
32
+        {:ok, data}
33
+      else
34
+        err -> err
35
+      end
36
+    end
37
+
38
+    @doc """
39
+    Stores the structured information of the ref'd Entry to object storage.
40
+
41
+    FIXME: Use expanded storage format below.
42
+    {
43
+      "version": "2018.12.21",
44
+      "properties": {},
45
+      "micropub": { // Proxy to IndieWeb.App.t
46
+        "client": "client_app_uri",
47
+        "occurred_at": "2018-12-21 14:15:37 PST"
48
+      }
49
+    }
50
+    """
51
+    @spec persist(record :: Entry.t(), data :: Map.t()) :: {:ok, String.t()} | {:error, any()}
52
+    def persist(record, data) do
53
+      case Koype.Storage.upload_properties_inline(data, record) do
54
+        {:error, _} = error -> error
55
+        {:ok, properties} -> Koype.Storage.Json.persist(record, properties)
56
+      end
57
+    end
58
+  end
59
+
60
+  def fetch(value), do: Koype.Repo.find_by_columns(__MODULE__, value, ~w(id slug)a)
61
+  def get_uri(entry), do: Repo.get_uri_for_record(entry, &entry_path/3)
62
+  def resolve_from_uri(url), do: fetch(Repo.resolve_token_from_uri(url, "post"))
63
+  def count, do: Repo.count(__MODULE__)
64
+
26 65
   def changeset(entry, attrs \\ :invalid) do
27 66
     entry
28 67
     |> cast(attrs, @required_attrs ++ @optional_attrs)
@@ -32,65 +71,37 @@ defmodule Koype.Repo.Entry do
32 71
     |> unique_constraint(:slug)
33 72
   end
34 73
 
35
-  def create(type, data) do
36
-    {:ok, post_types} = Post.determine_type(data)
74
+  # FIXME: Extract logic for handling slug generation (on-demand)
75
+  @doc "Creates a new entry."
76
+  @spec create(data :: map()) :: {:ok, struct()} | {:error, any()}
77
+  def create(data) do
78
+    post_types = Post.determine_type(data)
37 79
     post_type = Post.determine_dominant_type(post_types, data)
38 80
 
39
-    params = %{
40
-      "name" => Post.determine_title(post_type, data),
41
-      "post_type" => post_type,
42
-      "slug" => Map.get(data, "mp-slug", nil),
43
-      "type" => type
44
-    }
45
-
46
-    cs = changeset(%Entry{}, params)
47
-
48
-    case Repo.insert(cs) do
49
-      {:ok, record} ->
50
-        case ObjectData.craft(record, data) do
51
-          {:ok, _} -> {:ok, record}
52
-          {:error, error} -> {:error, error}
53
-        end
54
-
55
-      {:error, cs} ->
56
-        {:error, :failed_to_save_record, cs.errors}
81
+    cs =
82
+      changeset(%Entry{}, %{
83
+        name: Post.determine_title(post_type, data),
84
+        type: Atom.to_string(post_type),
85
+        slug: Map.get(data, "mp-slug", nil)
86
+      })
87
+
88
+    with(
89
+      {:ok, record} <- Repo.insert(cs),
90
+      {:ok, _} <- Json.persist(record, data)
91
+    ) do
92
+      {:ok, record}
93
+    else
94
+      {:error, _} = error -> error
57 95
     end
58 96
   end
59 97
 
60
-  @doc "Ensure there's a UUID in the given field"
61
-  @spec ensure_uuid(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
62
-  def ensure_uuid(changeset, field) do
63
-    case get_field(changeset, field) do
64
-      nil -> changeset |> put_change(field, Ecto.UUID.generate())
65
-      _ -> changeset
98
+  # FIXME: Hande updating of slug.
99
+  def update(model, properties) do
100
+    case Json.persist(model, properties) do
101
+      {:ok, _path} -> {:ok, model}
102
+      {:error, _} = error -> error
66 103
     end
67 104
   end
68
-
69
-  def delete(_entry), do: :ok
70
-  def undelete(_entry), do: :ok
71
-
72
-  def update(entry, properties) do
73
-    with({:ok, _data} <- ObjectData.craft(entry, properties)) do
74
-      if !Map.has_key?(properties, "slug") do
75
-        {:ok, entry}
76
-      else
77
-        %{"slug" => slug} = Map.take(properties, ["slug"])
78
-        cs = Ecto.Changeset.change(entry, %{slug: slug |> Enum.join()})
79
-
80
-        case Repo.update(cs) do
81
-          {:ok, record} -> {:ok, record}
82
-          {:error, cs} -> {:error, :failed_to_save_record, cs.errors}
83
-        end
84
-      end
85